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
96 changes: 96 additions & 0 deletions internal/custom/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
9 changes: 9 additions & 0 deletions internal/custom/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
115 changes: 98 additions & 17 deletions internal/custom/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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`,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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") + "."
}
}
100 changes: 100 additions & 0 deletions internal/custom/fallbackkey.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading