Skip to content

Commit 4bdc102

Browse files
authored
Merge pull request #11 from moderation-api/cli-key-fallback
Find the key from .env or modapi login; clearer doctor output
2 parents 5e4a9dd + dbe38ad commit 4bdc102

4 files changed

Lines changed: 303 additions & 17 deletions

File tree

‎internal/custom/commands_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,99 @@ func TestInitRejectsInvalidExistingKey(t *testing.T) {
300300
t.Fatalf("exit %d: %s", code, stderr)
301301
}
302302
}
303+
304+
func (f *fakeAPI) authFor(path string) []string {
305+
f.mu.Lock()
306+
defer f.mu.Unlock()
307+
var out []string
308+
for _, r := range f.requests {
309+
if r.Path == path {
310+
out = append(out, r.Auth)
311+
}
312+
}
313+
return out
314+
}
315+
316+
func writeEnv(t *testing.T, dir, name, content string) {
317+
t.Helper()
318+
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
319+
t.Fatal(err)
320+
}
321+
}
322+
323+
func TestAPICommandUsesProjectKeyFromEnvFile(t *testing.T) {
324+
dir := setup(t)
325+
api := newFakeAPI(t)
326+
writeEnv(t, dir, ".env", "MODAPI_SECRET_KEY=sk_proj_existing\n")
327+
t.Chdir(dir)
328+
329+
stdout, stderr, code := run(t, "account", "retrieve", "--server-url", api.URL, "-o", "json")
330+
if code != 0 {
331+
t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr)
332+
}
333+
if got := api.authFor("/account"); len(got) != 1 || got[0] != "Bearer sk_proj_existing" {
334+
t.Fatalf("Authorization = %v", got)
335+
}
336+
}
337+
338+
func TestAPICommandPrefersExplicitKey(t *testing.T) {
339+
dir := setup(t)
340+
api := newFakeAPI(t)
341+
writeEnv(t, dir, ".env", "MODAPI_SECRET_KEY=sk_proj_existing\n")
342+
t.Chdir(dir)
343+
t.Setenv("MODAPI_SECRET_KEY", "sk_proj_new")
344+
345+
if _, stderr, code := run(t, "account", "retrieve", "--server-url", api.URL); code != 0 {
346+
t.Fatalf("exit %d: %s", code, stderr)
347+
}
348+
if got := api.authFor("/account"); len(got) != 1 || got[0] != "Bearer sk_proj_new" {
349+
t.Fatalf("Authorization = %v", got)
350+
}
351+
}
352+
353+
func TestManagementCommandUsesOrganizationKey(t *testing.T) {
354+
dir := setup(t)
355+
api := newFakeAPI(t)
356+
writeEnv(t, dir, ".env", "MODAPI_SECRET_KEY=sk_proj_existing\n")
357+
t.Chdir(dir)
358+
t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test")
359+
360+
_, _, _ = run(t, "projects", "list", "--server-url", api.URL)
361+
if got := api.authFor("/projects"); len(got) != 1 || got[0] != "Bearer sk_org_test" {
362+
t.Fatalf("Authorization = %v", got)
363+
}
364+
}
365+
366+
func TestAPICommandWithoutAnyKeyStopsBeforeTheRequest(t *testing.T) {
367+
dir := setup(t)
368+
api := newFakeAPI(t)
369+
t.Chdir(dir)
370+
371+
_, stderr, code := run(t, "account", "retrieve", "--server-url", api.URL)
372+
if code != 3 {
373+
t.Fatalf("exit %d, want 3: %s", code, stderr)
374+
}
375+
if !strings.Contains(stderr, "modapi init") || !strings.Contains(stderr, "modapi login") {
376+
t.Fatalf("stderr does not say how to get a key: %s", stderr)
377+
}
378+
if paths := api.paths(); len(paths) != 0 {
379+
t.Fatalf("sent requests without a key: %v", paths)
380+
}
381+
}
382+
383+
func TestDoctorListsProblemsWithoutAnErrorBlock(t *testing.T) {
384+
dir := setup(t)
385+
386+
stdout, stderr, code := run(t, "doctor", "--dir", dir, "--agent-mode=false", "--color", "never")
387+
if code != 1 {
388+
t.Fatalf("exit %d, want 1\nstdout: %s\nstderr: %s", code, stdout, stderr)
389+
}
390+
for _, want := range []string{"Problems\n ✗ No project key found.\n Fix: modapi init\n", "Warnings\n", "1 problem, 2 warnings.\n"} {
391+
if !strings.Contains(stdout, want) {
392+
t.Fatalf("stdout misses %q:\n%s", want, stdout)
393+
}
394+
}
395+
if stderr != "" {
396+
t.Fatalf("doctor printed an error block: %s", stderr)
397+
}
398+
}

‎internal/custom/common.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ import (
2525
// Register adds the hand-written commands to the root command.
2626
func Register(root *cobra.Command) {
2727
root.AddCommand(newLoginCmd(), newLogoutCmd(), newInitCmd(), newDoctorCmd())
28+
preRun := root.PersistentPreRunE
29+
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
30+
if preRun != nil {
31+
if err := preRun(cmd, args); err != nil {
32+
return err
33+
}
34+
}
35+
return useFallbackKey(cmd)
36+
}
2837
}
2938

3039
// failure is an error the CLI's error renderer classifies by reason:

‎internal/custom/doctor.go‎

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/moderation-api/cli/internal/clierrors"
1212
"github.com/moderation-api/cli/internal/config"
1313
"github.com/moderation-api/cli/internal/flagutil"
14+
"github.com/moderation-api/cli/internal/output"
1415
"github.com/moderation-api/cli/internal/sdk/models/operations"
1516
"github.com/moderation-api/cli/internal/usage"
1617
)
@@ -26,7 +27,7 @@ files, then the keychain), checks it against the API, and checks the webhook
2627
signing secret, .gitignore, the SDK dependency and the modapi login session.
2728
2829
Each check has a status (ok, warn, fail or skip) and, when it is not ok, a
29-
remedy to run. doctor exits 1 when a check fails. Use -o json for the result
30+
fix to run. doctor exits 1 when a check fails. Use -o json for the result
3031
as data.`,
3132
Example: ` modapi doctor
3233
modapi doctor -o json`,
@@ -85,18 +86,19 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
8586
add(checkSDK(detected))
8687
add(checkLogin())
8788

88-
if err := render(cmd, result, func(w io.Writer) { printDoctor(w, &result) }); err != nil {
89+
colors := doctorColors(cmd)
90+
if err := render(cmd, result, func(w io.Writer) { printDoctor(w, &result, colors) }); err != nil {
8991
return err
9092
}
9193
if !result.OK {
92-
failed := 0
93-
for _, c := range result.Checks {
94-
if c.Status == "fail" {
95-
failed++
96-
}
94+
failed := countStatus(result.Checks, "fail")
95+
err := withHints(fmt.Errorf("%d check(s) failed", failed),
96+
"Run the fix listed for each problem, then run `modapi doctor` again")
97+
if !output.IsMachineMode(cmd) {
98+
// The summary above already says what failed and how to fix it.
99+
return shownError{err}
97100
}
98-
return clierrors.WithExitCode(withHints(fmt.Errorf("%d check(s) failed", failed),
99-
"Run the remedy listed for each failed check, then run `modapi doctor` again"), 1)
101+
return clierrors.WithExitCode(err, 1)
100102
}
101103
return nil
102104
}
@@ -206,15 +208,94 @@ func checkLogin() check {
206208
}
207209
}
208210

209-
func printDoctor(w io.Writer, r *doctorResult) {
210-
marks := map[string]string{"ok": "✓", "warn": "!", "fail": "✗", "skip": "-"}
211-
for _, c := range r.Checks {
212-
fmt.Fprintf(w, "%s %s\n", marks[c.Status], c.Message)
213-
if c.Remedy != "" {
214-
fmt.Fprintf(w, " → %s\n", c.Remedy)
211+
// shownError ends a command with exit code 1 after its output already
212+
// explained the failure, so no error block is printed below it.
213+
type shownError struct{ error }
214+
215+
func (shownError) Rendered() bool { return true }
216+
func (shownError) ExitCode() int { return 1 }
217+
func (e shownError) Unwrap() error { return e.error }
218+
219+
type palette struct{ red, yellow, green, dim, bold, reset string }
220+
221+
func doctorColors(cmd *cobra.Command) palette {
222+
flag, _ := flagutil.GetStringFlag(cmd, "color")
223+
if flag == "" {
224+
flag = "auto"
225+
}
226+
if !output.ShouldColorize(flag) {
227+
return palette{}
228+
}
229+
return palette{red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m", dim: "\x1b[2m", bold: "\x1b[1m", reset: "\x1b[0m"}
230+
}
231+
232+
func countStatus(checks []check, status string) int {
233+
n := 0
234+
for _, c := range checks {
235+
if c.Status == status {
236+
n++
215237
}
216238
}
217-
if r.OK {
218-
fmt.Fprintln(w, "\nAll required checks passed.")
239+
return n
240+
}
241+
242+
// printDoctor lists the problems first, then the warnings, then what passed,
243+
// each with the command that fixes it, and ends with a one-line summary.
244+
func printDoctor(w io.Writer, r *doctorResult, p palette) {
245+
groups := []struct {
246+
status, title, mark, color string
247+
}{
248+
{"fail", "Problems", "✗", p.red},
249+
{"warn", "Warnings", "!", p.yellow},
250+
{"skip", "Not set up", "-", p.dim},
251+
{"ok", "Passed", "✓", p.green},
252+
}
253+
first := true
254+
for _, g := range groups {
255+
if countStatus(r.Checks, g.status) == 0 {
256+
continue
257+
}
258+
if !first {
259+
fmt.Fprintln(w)
260+
}
261+
first = false
262+
fmt.Fprintf(w, "%s%s%s\n", p.bold, g.title, p.reset)
263+
for _, c := range r.Checks {
264+
if c.Status != g.status {
265+
continue
266+
}
267+
fmt.Fprintf(w, " %s%s%s %s\n", g.color, g.mark, p.reset, c.Message)
268+
if c.Remedy != "" && c.Status != "ok" {
269+
fmt.Fprintf(w, " %sFix:%s %s\n", p.dim, p.reset, c.Remedy)
270+
}
271+
}
272+
}
273+
274+
failed, warned := countStatus(r.Checks, "fail"), countStatus(r.Checks, "warn")
275+
fmt.Fprintln(w)
276+
switch {
277+
case failed > 0:
278+
fmt.Fprintf(w, "%s%s%s%s\n", p.red, p.bold, summary(failed, warned), p.reset)
279+
case warned > 0:
280+
fmt.Fprintf(w, "%sReady to use. %s%s\n", p.yellow, summary(0, warned), p.reset)
281+
default:
282+
fmt.Fprintf(w, "%sReady to use. All checks passed.%s\n", p.green, p.reset)
283+
}
284+
}
285+
286+
func summary(failed, warned int) string {
287+
plural := func(n int, word string) string {
288+
if n == 1 {
289+
return fmt.Sprintf("1 %s", word)
290+
}
291+
return fmt.Sprintf("%d %ss", n, word)
292+
}
293+
switch {
294+
case failed > 0 && warned > 0:
295+
return plural(failed, "problem") + ", " + plural(warned, "warning") + "."
296+
case failed > 0:
297+
return plural(failed, "problem") + "."
298+
default:
299+
return plural(warned, "warning") + "."
219300
}
220301
}

‎internal/custom/fallbackkey.go‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package custom
2+
3+
import (
4+
"os"
5+
6+
"github.com/spf13/cobra"
7+
8+
"github.com/moderation-api/cli/internal/client"
9+
"github.com/moderation-api/cli/internal/config"
10+
)
11+
12+
// commandsWithoutAPIKey are the top-level commands that do not call the API
13+
// with the secret key, or that find their own key.
14+
var commandsWithoutAPIKey = map[string]bool{
15+
"login": true, "logout": true, "init": true, "doctor": true,
16+
"auth": true, "configure": true, "whoami": true,
17+
"help": true, "completion": true, "version": true,
18+
}
19+
20+
// managementCommands call the management API, which takes an organization key.
21+
var managementCommands = map[string]bool{"projects": true, "channels": true}
22+
23+
// useFallbackKey runs before every command. When an API command has no
24+
// secret key (flag, MODAPI_SECRET_KEY, `modapi configure`), it uses the key
25+
// `modapi init` wrote to the project's env file in the current directory, or
26+
// the organization key from `modapi login` / MODAPI_ORGANIZATION_KEY. The
27+
// management commands try the organization key first. The key reaches the
28+
// request through MODAPI_SECRET_KEY, which the generated client reads.
29+
func useFallbackKey(cmd *cobra.Command) error {
30+
top := topLevelName(cmd)
31+
if top == "" || commandsWithoutAPIKey[top] {
32+
return nil
33+
}
34+
if key, _ := config.ResolveRequestSecurityCredential(cmd, "secret-key"); key != "" {
35+
return nil
36+
}
37+
38+
key, err := fallbackKey(managementCommands[top])
39+
if err != nil {
40+
return err
41+
}
42+
if key == "" {
43+
if client.IsDryRun(cmd) {
44+
return nil
45+
}
46+
return withHints(
47+
failure("CLI_AUTHENTICATION", "no API key: this command needs a project key or an organization key"),
48+
"Run `modapi init` in your project's directory to create or link a project and write its key to the env file",
49+
"Or run `modapi login` to use your organization key",
50+
"Or set MODAPI_SECRET_KEY, or pass --secret-key",
51+
)
52+
}
53+
return os.Setenv(projectKeyEnv, key)
54+
}
55+
56+
func fallbackKey(organizationFirst bool) (string, error) {
57+
projectKey := func() (string, error) {
58+
dir, err := os.Getwd()
59+
if err != nil {
60+
return "", nil
61+
}
62+
v, _ := findEnvValue(dir, detectStack(dir), projectKeyEnv)
63+
return v, nil
64+
}
65+
orgKey := func() (string, error) {
66+
if v := os.Getenv(organizationKeyEnv); v != "" {
67+
return v, nil
68+
}
69+
s, err := loadSession()
70+
if err != nil || s == nil {
71+
return "", err
72+
}
73+
return s.APIKey, nil
74+
}
75+
76+
order := []func() (string, error){projectKey, orgKey}
77+
if organizationFirst {
78+
order = []func() (string, error){orgKey, projectKey}
79+
}
80+
for _, find := range order {
81+
key, err := find()
82+
if err != nil {
83+
return "", err
84+
}
85+
if key != "" {
86+
return key, nil
87+
}
88+
}
89+
return "", nil
90+
}
91+
92+
// topLevelName is the name of the command right under the root.
93+
func topLevelName(cmd *cobra.Command) string {
94+
for c := cmd; c != nil; c = c.Parent() {
95+
if c.Parent() != nil && c.Parent().Parent() == nil {
96+
return c.Name()
97+
}
98+
}
99+
return ""
100+
}

0 commit comments

Comments
 (0)