diff --git a/internal/custom/commands_test.go b/internal/custom/commands_test.go index c9e890d..15c59df 100644 --- a/internal/custom/commands_test.go +++ b/internal/custom/commands_test.go @@ -300,3 +300,99 @@ func TestInitRejectsInvalidExistingKey(t *testing.T) { t.Fatalf("exit %d: %s", code, stderr) } } + +func (f *fakeAPI) authFor(path string) []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, r := range f.requests { + if r.Path == path { + out = append(out, r.Auth) + } + } + return out +} + +func writeEnv(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAPICommandUsesProjectKeyFromEnvFile(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + writeEnv(t, dir, ".env", "MODAPI_SECRET_KEY=sk_proj_existing\n") + t.Chdir(dir) + + stdout, stderr, code := run(t, "account", "retrieve", "--server-url", api.URL, "-o", "json") + if code != 0 { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + if got := api.authFor("/account"); len(got) != 1 || got[0] != "Bearer sk_proj_existing" { + t.Fatalf("Authorization = %v", got) + } +} + +func TestAPICommandPrefersExplicitKey(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + writeEnv(t, dir, ".env", "MODAPI_SECRET_KEY=sk_proj_existing\n") + t.Chdir(dir) + t.Setenv("MODAPI_SECRET_KEY", "sk_proj_new") + + if _, stderr, code := run(t, "account", "retrieve", "--server-url", api.URL); code != 0 { + t.Fatalf("exit %d: %s", code, stderr) + } + if got := api.authFor("/account"); len(got) != 1 || got[0] != "Bearer sk_proj_new" { + t.Fatalf("Authorization = %v", got) + } +} + +func TestManagementCommandUsesOrganizationKey(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + writeEnv(t, dir, ".env", "MODAPI_SECRET_KEY=sk_proj_existing\n") + t.Chdir(dir) + t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test") + + _, _, _ = run(t, "projects", "list", "--server-url", api.URL) + if got := api.authFor("/projects"); len(got) != 1 || got[0] != "Bearer sk_org_test" { + t.Fatalf("Authorization = %v", got) + } +} + +func TestAPICommandWithoutAnyKeyStopsBeforeTheRequest(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + t.Chdir(dir) + + _, stderr, code := run(t, "account", "retrieve", "--server-url", api.URL) + if code != 3 { + t.Fatalf("exit %d, want 3: %s", code, stderr) + } + if !strings.Contains(stderr, "modapi init") || !strings.Contains(stderr, "modapi login") { + t.Fatalf("stderr does not say how to get a key: %s", stderr) + } + if paths := api.paths(); len(paths) != 0 { + t.Fatalf("sent requests without a key: %v", paths) + } +} + +func TestDoctorListsProblemsWithoutAnErrorBlock(t *testing.T) { + dir := setup(t) + + stdout, stderr, code := run(t, "doctor", "--dir", dir, "--agent-mode=false", "--color", "never") + if code != 1 { + t.Fatalf("exit %d, want 1\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + for _, want := range []string{"Problems\n ✗ No project key found.\n Fix: modapi init\n", "Warnings\n", "1 problem, 2 warnings.\n"} { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout misses %q:\n%s", want, stdout) + } + } + if stderr != "" { + t.Fatalf("doctor printed an error block: %s", stderr) + } +} diff --git a/internal/custom/common.go b/internal/custom/common.go index 2c2a32c..1260d0a 100644 --- a/internal/custom/common.go +++ b/internal/custom/common.go @@ -25,6 +25,15 @@ import ( // Register adds the hand-written commands to the root command. func Register(root *cobra.Command) { root.AddCommand(newLoginCmd(), newLogoutCmd(), newInitCmd(), newDoctorCmd()) + preRun := root.PersistentPreRunE + root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if preRun != nil { + if err := preRun(cmd, args); err != nil { + return err + } + } + return useFallbackKey(cmd) + } } // failure is an error the CLI's error renderer classifies by reason: diff --git a/internal/custom/doctor.go b/internal/custom/doctor.go index b13ab6e..c334772 100644 --- a/internal/custom/doctor.go +++ b/internal/custom/doctor.go @@ -11,6 +11,7 @@ import ( "github.com/moderation-api/cli/internal/clierrors" "github.com/moderation-api/cli/internal/config" "github.com/moderation-api/cli/internal/flagutil" + "github.com/moderation-api/cli/internal/output" "github.com/moderation-api/cli/internal/sdk/models/operations" "github.com/moderation-api/cli/internal/usage" ) @@ -26,7 +27,7 @@ files, then the keychain), checks it against the API, and checks the webhook signing secret, .gitignore, the SDK dependency and the modapi login session. Each check has a status (ok, warn, fail or skip) and, when it is not ok, a -remedy to run. doctor exits 1 when a check fails. Use -o json for the result +fix to run. doctor exits 1 when a check fails. Use -o json for the result as data.`, Example: ` modapi doctor modapi doctor -o json`, @@ -85,18 +86,19 @@ func runDoctor(cmd *cobra.Command, _ []string) error { add(checkSDK(detected)) add(checkLogin()) - if err := render(cmd, result, func(w io.Writer) { printDoctor(w, &result) }); err != nil { + colors := doctorColors(cmd) + if err := render(cmd, result, func(w io.Writer) { printDoctor(w, &result, colors) }); err != nil { return err } if !result.OK { - failed := 0 - for _, c := range result.Checks { - if c.Status == "fail" { - failed++ - } + failed := countStatus(result.Checks, "fail") + err := withHints(fmt.Errorf("%d check(s) failed", failed), + "Run the fix listed for each problem, then run `modapi doctor` again") + if !output.IsMachineMode(cmd) { + // The summary above already says what failed and how to fix it. + return shownError{err} } - return clierrors.WithExitCode(withHints(fmt.Errorf("%d check(s) failed", failed), - "Run the remedy listed for each failed check, then run `modapi doctor` again"), 1) + return clierrors.WithExitCode(err, 1) } return nil } @@ -206,15 +208,94 @@ func checkLogin() check { } } -func printDoctor(w io.Writer, r *doctorResult) { - marks := map[string]string{"ok": "✓", "warn": "!", "fail": "✗", "skip": "-"} - for _, c := range r.Checks { - fmt.Fprintf(w, "%s %s\n", marks[c.Status], c.Message) - if c.Remedy != "" { - fmt.Fprintf(w, " → %s\n", c.Remedy) +// shownError ends a command with exit code 1 after its output already +// explained the failure, so no error block is printed below it. +type shownError struct{ error } + +func (shownError) Rendered() bool { return true } +func (shownError) ExitCode() int { return 1 } +func (e shownError) Unwrap() error { return e.error } + +type palette struct{ red, yellow, green, dim, bold, reset string } + +func doctorColors(cmd *cobra.Command) palette { + flag, _ := flagutil.GetStringFlag(cmd, "color") + if flag == "" { + flag = "auto" + } + if !output.ShouldColorize(flag) { + return palette{} + } + return palette{red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m", dim: "\x1b[2m", bold: "\x1b[1m", reset: "\x1b[0m"} +} + +func countStatus(checks []check, status string) int { + n := 0 + for _, c := range checks { + if c.Status == status { + n++ } } - if r.OK { - fmt.Fprintln(w, "\nAll required checks passed.") + return n +} + +// printDoctor lists the problems first, then the warnings, then what passed, +// each with the command that fixes it, and ends with a one-line summary. +func printDoctor(w io.Writer, r *doctorResult, p palette) { + groups := []struct { + status, title, mark, color string + }{ + {"fail", "Problems", "✗", p.red}, + {"warn", "Warnings", "!", p.yellow}, + {"skip", "Not set up", "-", p.dim}, + {"ok", "Passed", "✓", p.green}, + } + first := true + for _, g := range groups { + if countStatus(r.Checks, g.status) == 0 { + continue + } + if !first { + fmt.Fprintln(w) + } + first = false + fmt.Fprintf(w, "%s%s%s\n", p.bold, g.title, p.reset) + for _, c := range r.Checks { + if c.Status != g.status { + continue + } + fmt.Fprintf(w, " %s%s%s %s\n", g.color, g.mark, p.reset, c.Message) + if c.Remedy != "" && c.Status != "ok" { + fmt.Fprintf(w, " %sFix:%s %s\n", p.dim, p.reset, c.Remedy) + } + } + } + + failed, warned := countStatus(r.Checks, "fail"), countStatus(r.Checks, "warn") + fmt.Fprintln(w) + switch { + case failed > 0: + fmt.Fprintf(w, "%s%s%s%s\n", p.red, p.bold, summary(failed, warned), p.reset) + case warned > 0: + fmt.Fprintf(w, "%sReady to use. %s%s\n", p.yellow, summary(0, warned), p.reset) + default: + fmt.Fprintf(w, "%sReady to use. All checks passed.%s\n", p.green, p.reset) + } +} + +func summary(failed, warned int) string { + plural := func(n int, word string) string { + if n == 1 { + return fmt.Sprintf("1 %s", word) + } + return fmt.Sprintf("%d %ss", n, word) + } + switch { + case failed > 0 && warned > 0: + return plural(failed, "problem") + ", " + plural(warned, "warning") + "." + case failed > 0: + return plural(failed, "problem") + "." + default: + return plural(warned, "warning") + "." } } diff --git a/internal/custom/fallbackkey.go b/internal/custom/fallbackkey.go new file mode 100644 index 0000000..b406a83 --- /dev/null +++ b/internal/custom/fallbackkey.go @@ -0,0 +1,100 @@ +package custom + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/moderation-api/cli/internal/client" + "github.com/moderation-api/cli/internal/config" +) + +// commandsWithoutAPIKey are the top-level commands that do not call the API +// with the secret key, or that find their own key. +var commandsWithoutAPIKey = map[string]bool{ + "login": true, "logout": true, "init": true, "doctor": true, + "auth": true, "configure": true, "whoami": true, + "help": true, "completion": true, "version": true, +} + +// managementCommands call the management API, which takes an organization key. +var managementCommands = map[string]bool{"projects": true, "channels": true} + +// useFallbackKey runs before every command. When an API command has no +// secret key (flag, MODAPI_SECRET_KEY, `modapi configure`), it uses the key +// `modapi init` wrote to the project's env file in the current directory, or +// the organization key from `modapi login` / MODAPI_ORGANIZATION_KEY. The +// management commands try the organization key first. The key reaches the +// request through MODAPI_SECRET_KEY, which the generated client reads. +func useFallbackKey(cmd *cobra.Command) error { + top := topLevelName(cmd) + if top == "" || commandsWithoutAPIKey[top] { + return nil + } + if key, _ := config.ResolveRequestSecurityCredential(cmd, "secret-key"); key != "" { + return nil + } + + key, err := fallbackKey(managementCommands[top]) + if err != nil { + return err + } + if key == "" { + if client.IsDryRun(cmd) { + return nil + } + return withHints( + failure("CLI_AUTHENTICATION", "no API key: this command needs a project key or an organization key"), + "Run `modapi init` in your project's directory to create or link a project and write its key to the env file", + "Or run `modapi login` to use your organization key", + "Or set MODAPI_SECRET_KEY, or pass --secret-key", + ) + } + return os.Setenv(projectKeyEnv, key) +} + +func fallbackKey(organizationFirst bool) (string, error) { + projectKey := func() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", nil + } + v, _ := findEnvValue(dir, detectStack(dir), projectKeyEnv) + return v, nil + } + orgKey := func() (string, error) { + if v := os.Getenv(organizationKeyEnv); v != "" { + return v, nil + } + s, err := loadSession() + if err != nil || s == nil { + return "", err + } + return s.APIKey, nil + } + + order := []func() (string, error){projectKey, orgKey} + if organizationFirst { + order = []func() (string, error){orgKey, projectKey} + } + for _, find := range order { + key, err := find() + if err != nil { + return "", err + } + if key != "" { + return key, nil + } + } + return "", nil +} + +// topLevelName is the name of the command right under the root. +func topLevelName(cmd *cobra.Command) string { + for c := cmd; c != nil; c = c.Parent() { + if c.Parent() != nil && c.Parent().Parent() == nil { + return c.Name() + } + } + return "" +}