diff --git a/README.md b/README.md index 06225d4..6c8c2ff 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,27 @@ id=$(nan run --detach "upgrade the dependencies") | `--ws NAME` | your only workspace | Workspace to run in. Required when you have more than one. | | `--agent pi\|hermes` | `pi` | Agent to run. | | `--model M` | the agent's own | Model for the agent. | -| `--cwd PATH` | `/home/nan` | Directory in the workspace. | -| `--worktree` / `--no-worktree` | auto | Run in a separate git worktree (auto: when `--cwd` is in a git repo). | +| `--cwd PATH` | `/home/nan` | Folder in the workspace. | +| `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. | | `--timeout D` | `30m` | Stop the run after this long, `1m` to `2h`. | | `--detach` | | Queue the run, print its id and return. | | `--json` | | Events as JSON lines, then the finished run object. With `--detach`, the run object. | | `--idempotency-key K` | | Retrying with the same key and request returns the run already created. | | `-f FILE` | | Read the prompt from a file (`-` for stdin). | +| `--token-file PATH` | | Authenticate with the token on the first line of `PATH`. See [Authentication](#authentication). | + +**Git branch.** By default (auto) a run inside a git repository works on its +own branch, `nan-run/`, and commits there. It +never pushes, and your checkout is untouched. Outside a repository it works in +the folder itself. `--worktree` forces a branch (and fails outside a +repository); `--no-worktree` works in the folder even inside a repository. + +**Queue.** If the workspace is already running as many runs as its size +allows, the run waits as `queued` and starts when one of them ends. + +**In the portal** the same settings have other names: Folder is `--cwd`, +Separate branch is `--worktree` / `--no-worktree`, Time limit is `--timeout`, +and Task is the prompt. While it streams, **Ctrl-C** detaches and leaves the run going, and prints how to pick it up again. A second Ctrl-C within 2 seconds cancels the run. @@ -154,8 +168,52 @@ branch on it without parsing text: | 69 | nan.builders unavailable, or the stream was lost after retries (the run goes on; the id is printed) | | 75 | `--detach`, or Ctrl-C: the run was accepted and is still going | -The runs commands use your `nan auth login` session, or the API key from the -Setup tab when there is no session. +Full reference, including the event types `nan runs logs --json` prints (one +JSON object per line): . + +### Authentication + +`nan run` and `nan runs` use the first of these they find: + +1. **`NAN_TOKEN`**: an environment variable holding a platform token + (`nan_pat_...`) or an API key (`sk-...`). Read on every command, never + written to disk. +2. **`--token-file PATH`**: the token on the first line of a file. The file + must be private (`chmod 600`); one that other users can read or write is + refused. +3. **A saved token**, from `nan auth login --api-token`. +4. **Your session**, from `nan auth login` (the emailed sign-in link). +5. **A saved API key**, from the Setup tab (or an `sk-...` key saved with + `nan auth login --api-token`). + +Create platform tokens in **Settings > Tokens** at +. A token works for runs and for listing your +workspaces, and nothing else: it is not an inference key, and `nan me`, +`nan metrics` and the dashboard still need `nan auth login`. + +**Headless: CI or a server.** Keep the token in your CI's secret store and +expose it as `NAN_TOKEN`. For example, in GitHub Actions: + +```yaml +- name: Review the PR with an agent + env: + NAN_TOKEN: ${{ secrets.NAN_TOKEN }} + run: | + curl -fsSL https://nan.builders/install | bash + nan run --ws ci --timeout 20m -f .github/review-task.md +``` + +On a machine you set up once, save the token instead. It is read from stdin +(hidden when you paste it), checked against nan.builders, and stored in +`~/.config/nan/session.json` with mode `0600`: + +```bash +nan auth login --api-token # paste it when asked +nan auth login --api-token < token.txt # or pipe it in +``` + +Never put a token on the command line itself: it would end up in your shell +history and in `ps`. ## Build from source diff --git a/SECURITY.md b/SECURITY.md index 02f0182..b886dcc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,12 @@ secrets file of its own: there the key goes into its `.env` and the config carries a `${NAN_API_KEY}` reference, which is also why it never reaches a command line. +The same holds for a platform token saved with `nan auth login --api-token`: +it goes into `session.json` (and nowhere else, since it is not an inference +key), read from stdin so it never reaches a command line. `NAN_TOKEN` is +never written anywhere, and a `--token-file` that other users can read or +write is refused rather than used. + **On Windows the mode bits do nothing.** The files inherit the ACL of the user profile they sit in, which already excludes other non-administrator accounts, and an administrator can take ownership regardless. If you keep your home diff --git a/cmd/auth.go b/cmd/auth.go index 80b639c..f0cf1f8 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -2,11 +2,15 @@ package cmd import ( "bufio" + "context" + "errors" "fmt" "io" "os" "strings" + "github.com/charmbracelet/x/term" + "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/auth" "github.com/nxssie/nan-cli/internal/session" "github.com/nxssie/nan-cli/internal/tui" @@ -18,6 +22,7 @@ var ( emailFlag string linkFlag string keepToolsFlag bool + apiTokenFlag bool ) var authCmd = &cobra.Command{ @@ -27,13 +32,26 @@ var authCmd = &cobra.Command{ var loginCmd = &cobra.Command{ Use: "login", - Short: "Log in with a sign-in link sent to your email", - RunE: runLogin, + Short: "Log in by email, or save a platform token with --api-token", + Long: `Log in with a sign-in link sent to your email. + +With --api-token, save a platform token instead, for a machine with nobody +at the keyboard (a server, a CI runner). Create the token in Settings > +Tokens at https://cloud.nan.builders. It is read from stdin only, so it +never ends up in your shell history or in ps. It is checked against +nan.builders and saved in ~/.config/nan/session.json, readable only by you. +Tokens work for nan run and nan runs; the other commands and the dashboard +need the email sign-in. An API key (sk-...) can be saved the same way; it +replaces the one the Setup tab saved.`, + Example: ` nan auth login --email you@example.com + nan auth login --api-token # paste the token; it is not shown + nan auth login --api-token < token.txt`, + RunE: runLogin, } var logoutCmd = &cobra.Command{ Use: "logout", - Short: "Log out and delete local session", + Short: "Log out: delete the local session, saved token and API key", RunE: runLogout, } @@ -44,6 +62,7 @@ func init() { loginCmd.Flags().StringVar(&emailFlag, "email", "", "Email to send the sign-in link to") loginCmd.Flags().StringVar(&linkFlag, "link", "", `Finish the login with the link from the email ("-" reads it from stdin, keeping the token out of your shell history)`) loginCmd.Flags().StringVar(&tokenFlag, "token", "", `Save a nan_session token directly, skipping the email ("-" reads it from stdin, keeping it out of your shell history)`) + loginCmd.Flags().BoolVar(&apiTokenFlag, "api-token", false, "Save a platform token (nan_pat_...) or API key (sk-...) for nan run and nan runs, read from stdin") logoutCmd.Flags().BoolVar(&keepToolsFlag, "keep-tools", false, "Leave the API key in the tools this CLI configured") } @@ -52,6 +71,12 @@ func init() { // answered 404 since that flow was retired: the browser landed on an error page // and the command then asked for a cookie that no longer existed. func runLogin(cmd *cobra.Command, args []string) error { + if apiTokenFlag { + if tokenFlag != "" || linkFlag != "" || emailFlag != "" { + return fmt.Errorf("--api-token cannot be combined with --email, --link or --token") + } + return loginWithAPIToken(cmd.Context(), newTokenInput(os.Stdin), os.Stdout, os.Stderr) + } if tokenFlag != "" { token, err := flagValue(tokenFlag) if err != nil { @@ -212,3 +237,92 @@ func saveToken(token string) error { fmt.Println("Logged in successfully.") return nil } + +// tokenInput is where `nan auth login --api-token` reads the token from. On +// a terminal it is read without echo; anywhere else, the first line of stdin. +type tokenInput struct { + r io.Reader + tty bool + readHidden func() ([]byte, error) +} + +// newTokenInput reads from f, hidden when f is a terminal. Asking the +// terminal itself, not the file mode: /dev/null is a character device too, +// and under cron or `docker run` without -i that is what stdin is. +func newTokenInput(f *os.File) tokenInput { + return tokenInput{ + r: f, + tty: term.IsTerminal(f.Fd()), + readHidden: func() ([]byte, error) { return term.ReadPassword(f.Fd()) }, + } +} + +func (in tokenInput) read(prompt io.Writer) (string, error) { + if in.tty { + fmt.Fprint(prompt, "Paste the token (it is not shown): ") + raw, err := in.readHidden() + fmt.Fprintln(prompt) + if err != nil { + return "", fmt.Errorf("could not read the token: %w", err) + } + return strings.TrimSpace(string(raw)), nil + } + line, err := bufio.NewReader(io.LimitReader(in.r, maxTokenLen+2)).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("could not read the token from stdin: %w", err) + } + return strings.TrimSpace(line), nil +} + +// loginWithAPIToken saves a platform token (or an API key) for the run +// commands, after nan.builders has accepted it. Nothing is saved otherwise, +// and the token is never printed. +func loginWithAPIToken(ctx context.Context, in tokenInput, out, prompt io.Writer) error { + tok, err := in.read(prompt) + if err != nil { + return err + } + if tok == "" { + return fmt.Errorf("no token arrived on stdin: paste it when asked, or pipe it in: nan auth login --api-token < token.txt") + } + if !validTokenShape(tok) { + return fmt.Errorf("that is not a valid token, nothing was saved: %s", tokenShapeHint) + } + + client := api.NewRunsClient("", tok).WithBaseURL(runsBaseURL) + if _, err := client.ListRuns(ctx, api.ListRunsParams{Limit: 1}); err != nil { + if errors.Is(err, api.ErrSessionExpired) { + return fmt.Errorf("nan.builders refused that token (revoked, expired or mistyped), nothing was saved: create one in Settings > Tokens at https://cloud.nan.builders") + } + return fmt.Errorf("could not check the token, nothing was saved: %v", apiExit(err, credential{kind: credStoredToken}).Err) + } + + current, err := session.Load() + switch { + case errors.Is(err, session.ErrNotLoggedIn): + current = &session.Session{} + case err != nil: + return fmt.Errorf("could not read %s, nothing was saved: %w", session.Path(), err) + } + isKey := strings.HasPrefix(tok, "sk-") + replacedKey := isKey && current.APIKey != "" && current.APIKey != tok + if isKey { + current.APIKey = tok + } else { + current.PlatformToken = tok + } + if err := session.Save(current); err != nil { + return fmt.Errorf("could not save the token: %w", err) + } + + fmt.Fprintf(out, "Token saved to %s. nan run and nan runs will use it on this machine.\n", session.Path()) + if replacedKey { + fmt.Fprintln(out, "It replaces the API key saved before. Tools the Setup tab configured keep the old key until you apply Setup again.") + } + if isKey && current.Token != "" { + // The session goes first and nothing here knows when it expires, so + // saying "while it lasts" would promise a fallback that never happens. + fmt.Fprintln(out, "You are also signed in by email, and nan run uses that session first, even after it expires. To use only this key: nan auth logout, then save it again.") + } + return nil +} diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 3b32ddd..93cd141 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -1,8 +1,17 @@ package cmd import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -62,3 +71,257 @@ func withStdin(t *testing.T, content string) { f.Close() }) } + +// tokenServer answers GET /v1/runs?limit=1 with status, and records what it +// was asked. +func tokenServer(t *testing.T, status int) (*int, *string) { + t.Helper() + calls, auth := new(int), new(string) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *calls++ + *auth = r.Header.Get("Authorization") + if r.URL.Path != "/v1/runs" || r.URL.Query().Get("limit") != "1" { + t.Errorf("unexpected request %s", r.URL) + } + w.WriteHeader(status) + if status == http.StatusOK { + _, _ = io.WriteString(w, `{"object":"list","data":[],"next_cursor":null}`) + return + } + _, _ = io.WriteString(w, `{"error":{"message":"invalid token","code":"invalid_api_key"}}`) + })) + t.Cleanup(srv.Close) + old := runsBaseURL + runsBaseURL = srv.URL + t.Cleanup(func() { runsBaseURL = old }) + return calls, auth +} + +func readSession(t *testing.T, home string) (map[string]any, os.FileMode) { + t.Helper() + path := filepath.Join(home, ".config", "nan", "session.json") + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, 0 + } + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + return m, info.Mode().Perm() +} + +func pipedToken(s string) tokenInput { return tokenInput{r: strings.NewReader(s)} } + +func TestAPITokenLoginSavesAVerifiedToken(t *testing.T) { + for _, c := range []struct { + name, tok, field string + }{ + {"platform token", testPAT, "platformToken"}, + {"api key", testKey, "apiKey"}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, `{"token":"","enabledTools":{"pi":true}}`) + calls, auth := tokenServer(t, http.StatusOK) + var out, prompt bytes.Buffer + if err := loginWithAPIToken(context.Background(), pipedToken(c.tok+"\n"), &out, &prompt); err != nil { + t.Fatal(err) + } + if *calls != 1 || *auth != "Bearer "+c.tok { + t.Errorf("verification: %d calls, Authorization %q", *calls, *auth) + } + sess, mode := readSession(t, home) + if sess[c.field] != c.tok { + t.Errorf("session.json = %v", sess) + } + if sess["enabledTools"] == nil { + t.Error("saving the token dropped the rest of the session") + } + if runtime.GOOS != "windows" && mode != 0o600 { + t.Errorf("session.json mode %04o", mode) + } + if strings.Contains(out.String()+prompt.String(), "SECRET") { + t.Errorf("token printed: %q %q", out.String(), prompt.String()) + } + }) + } +} + +// A platform token must not land in apiKey: the Setup tab writes apiKey into +// every tool it configures, and the inference API refuses platform tokens. +func TestAPITokenLoginKeepsPlatformTokensOutOfAPIKey(t *testing.T) { + home := fakeHome(t, `{"token":"","apiKey":"`+testKey+`"}`) + tokenServer(t, http.StatusOK) + if err := loginWithAPIToken(context.Background(), pipedToken(testPAT), io.Discard, io.Discard); err != nil { + t.Fatal(err) + } + sess, _ := readSession(t, home) + if sess["apiKey"] != testKey || sess["platformToken"] != testPAT { + t.Errorf("session.json = %v", sess) + } +} + +func TestAPITokenLoginSavesNothingOnFailure(t *testing.T) { + for _, c := range []struct { + name string + input string + status int + want string + calls int + }{ + {"empty stdin", "", http.StatusOK, "no token arrived", 0}, + {"wrong shape", "ghp_SECRETnotours\n", http.StatusOK, "not a valid token", 0}, + {"refused", testPAT, http.StatusUnauthorized, "refused that token", 1}, + {"platform down", testPAT, http.StatusBadGateway, "could not check the token", 1}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, "") + calls, _ := tokenServer(t, c.status) + var out bytes.Buffer + err := loginWithAPIToken(context.Background(), pipedToken(c.input), &out, io.Discard) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + if *calls != c.calls { + t.Errorf("%d requests, want %d", *calls, c.calls) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v after a failure", sess) + } + if strings.Contains(err.Error()+out.String(), "SECRET") { + t.Errorf("token echoed: %v", err) + } + }) + } +} + +// On a terminal the token is read without echo, after a prompt on stderr. +func TestAPITokenLoginOnATerminalReadsHidden(t *testing.T) { + home := fakeHome(t, `{"token":"sess-1"}`) + tokenServer(t, http.StatusOK) + hidden := false + in := tokenInput{tty: true, readHidden: func() ([]byte, error) { hidden = true; return []byte(testPAT + "\n"), nil }} + var out, prompt bytes.Buffer + if err := loginWithAPIToken(context.Background(), in, &out, &prompt); err != nil { + t.Fatal(err) + } + if !hidden || !strings.Contains(prompt.String(), "not shown") { + t.Errorf("hidden=%v prompt=%q", hidden, prompt.String()) + } + if sess, _ := readSession(t, home); sess["token"] != "sess-1" || sess["platformToken"] != testPAT { + t.Errorf("session.json = %v", sess) + } +} + +func TestAPITokenCannotBeCombined(t *testing.T) { + fakeHome(t, "") + defer func() { apiTokenFlag, emailFlag = false, "" }() + rootCmd.SetArgs([]string{"auth", "login", "--api-token", "--email", "a@b.c"}) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + if err := rootCmd.Execute(); err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Errorf("err = %v", err) + } +} + +// Only the first line of stdin is the token: what follows cannot become part +// of it, or of a header. +func TestAPITokenLoginTakesOnlyTheFirstLine(t *testing.T) { + home := fakeHome(t, "") + _, auth := tokenServer(t, http.StatusOK) + if err := loginWithAPIToken(context.Background(), pipedToken("nan_pat_a\r\nX-SECRET: 1\n"), io.Discard, io.Discard); err != nil { + t.Fatal(err) + } + if *auth != "Bearer nan_pat_a" { + t.Errorf("Authorization = %q", *auth) + } + if sess, _ := readSession(t, home); sess["platformToken"] != "nan_pat_a" { + t.Errorf("session.json = %v", sess) + } +} + +// Under cron, or `docker run` without -i, stdin is /dev/null: a character +// device, but not a terminal. That is "nothing arrived", not a failed +// attempt at a hidden prompt. +func TestAPITokenLoginWithStdinFromDevNull(t *testing.T) { + fakeHome(t, "") + calls, _ := tokenServer(t, http.StatusOK) + f, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer f.Close() + in := newTokenInput(f) + if in.tty { + t.Fatal(os.DevNull + " taken for a terminal") + } + var prompt bytes.Buffer + err = loginWithAPIToken(context.Background(), in, io.Discard, &prompt) + if err == nil || !strings.Contains(err.Error(), "no token arrived") { + t.Errorf("err = %v", err) + } + if prompt.Len() != 0 || *calls != 0 { + t.Errorf("prompted %q, %d requests", prompt.String(), *calls) + } +} + +// What the login says afterwards matches what the run commands will do. +func TestAPITokenLoginSaysWhatWillBeUsed(t *testing.T) { + for _, c := range []struct { + name, session, tok string + want, notWant []string + }{ + {"token with a session", `{"token":"sess-1"}`, testPAT, nil, []string{"session first", "replaces"}}, + {"key with a session", `{"token":"sess-1"}`, testKey, []string{"session first, even after it expires", "nan auth logout"}, []string{"replaces"}}, + {"key over another key", `{"token":"","apiKey":"sk-old"}`, testKey, []string{"replaces the API key", "apply Setup again"}, []string{"session first"}}, + {"same key again", `{"token":"","apiKey":"` + testKey + `"}`, testKey, nil, []string{"replaces", "session first"}}, + } { + t.Run(c.name, func(t *testing.T) { + fakeHome(t, c.session) + tokenServer(t, http.StatusOK) + var out bytes.Buffer + if err := loginWithAPIToken(context.Background(), pipedToken(c.tok), &out, io.Discard); err != nil { + t.Fatal(err) + } + for _, w := range c.want { + if !strings.Contains(out.String(), w) { + t.Errorf("%q does not say %q", out.String(), w) + } + } + for _, w := range c.notWant { + if strings.Contains(out.String(), w) { + t.Errorf("%q says %q", out.String(), w) + } + } + }) + } +} + +// A terminal that cannot be read hidden fails with the reason, ends the +// prompt line, and saves nothing. +func TestAPITokenLoginHiddenReadFailure(t *testing.T) { + home := fakeHome(t, "") + calls, _ := tokenServer(t, http.StatusOK) + in := tokenInput{tty: true, readHidden: func() ([]byte, error) { return nil, errors.New("not supported") }} + var prompt bytes.Buffer + err := loginWithAPIToken(context.Background(), in, io.Discard, &prompt) + if err == nil || !strings.Contains(err.Error(), "could not read the token") { + t.Fatalf("err = %v", err) + } + if !strings.HasSuffix(prompt.String(), "\n") { + t.Errorf("prompt line left open: %q", prompt.String()) + } + if *calls != 0 { + t.Errorf("%d requests", *calls) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} diff --git a/cmd/credentials.go b/cmd/credentials.go new file mode 100644 index 0000000..4a469ad --- /dev/null +++ b/cmd/credentials.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/nxssie/nan-cli/internal/session" +) + +// tokenEnvVar holds a platform token (nan_pat_...) or an API key (sk-...) +// for `nan run` and `nan runs`. It is read, never written anywhere. +const tokenEnvVar = session.TokenEnvVar // short name for the many messages that print it + +// maxTokenLen bounds what is accepted as a token. Real ones are far shorter; +// anything longer is a file that is not a token. +const maxTokenLen = 512 + +// runsBaseURL is where the run commands send requests. A variable so the +// tests can point it at a server of their own. +var runsBaseURL = api.RunsBaseURL + +// tokenFileFlag is --token-file on `nan run` and `nan runs`. +var tokenFileFlag string + +const tokenFileUsage = "Read the token (nan_pat_... or sk-...) from the first line of `PATH`, a file only you can read (chmod 600)" + +type credKind int + +const ( + credSession credKind = iota + credStoredToken + credStoredKey + credEnv + credFile +) + +// credential is what a run command authenticates with, and where it came +// from: an error about a refused credential has to name the one that was +// refused, never its value. +type credential struct { + kind credKind + path string // the --token-file, for credFile + session string // the nan_session token, for credSession + bearer string // a nan_pat_ token or an sk- key, for everything else +} + +// describe names the credential for an error message. +func (c credential) describe() string { + switch c.kind { + case credEnv: + return "the token in " + tokenEnvVar + case credFile: + return "the token in " + runs.SanitizeLine(c.path) + case credStoredToken: + return "your saved platform token" + case credStoredKey: + return "your saved API key" + } + return "your session" +} + +func (c credential) client() *api.RunsClient { + return api.NewRunsClient(c.session, c.bearer).WithBaseURL(runsBaseURL) +} + +// validTokenShape is the check done before a token is sent anywhere: a +// platform token or an API key, printable ASCII, and nothing that could +// split an HTTP header. +func validTokenShape(tok string) bool { + var prefix string + switch { + case strings.HasPrefix(tok, "nan_pat_"): + prefix = "nan_pat_" + case strings.HasPrefix(tok, "sk-"): + prefix = "sk-" + default: + return false + } + if len(tok) <= len(prefix) || len(tok) > maxTokenLen { + return false + } + for i := 0; i < len(tok); i++ { + if tok[i] < 0x21 || tok[i] > 0x7e { + return false + } + } + return true +} + +const tokenShapeHint = "expected a platform token (nan_pat_...) from Settings > Tokens at https://cloud.nan.builders, or an API key (sk-...)" + +// resolveCredential picks what the run commands authenticate with, first +// match wins: +// +// 1. NAN_TOKEN +// 2. --token-file PATH +// 3. a platform token saved with `nan auth login --api-token` +// 4. the session from `nan auth login` +// 5. the API key saved by the Setup tab (or by --api-token, for an sk- key) +// +// A saved platform token goes before the session because it was saved for +// exactly these commands, and because a session expires on the server with +// nothing on this side knowing: ranked after it, the token would never be +// reached on a machine that once signed in by email. +func resolveCredential(getenv func(string) string, tokenFile string) (credential, error) { + if v := getenv(tokenEnvVar); v != "" { + tok := strings.TrimSpace(v) + if !validTokenShape(tok) { + return credential{}, exitf(exitAuth, "%s is set but does not hold a valid token: %s", tokenEnvVar, tokenShapeHint) + } + return credential{kind: credEnv, bearer: tok}, nil + } + if tokenFile != "" { + tok, err := readTokenFile(tokenFile) + if err != nil { + return credential{}, err + } + return credential{kind: credFile, path: tokenFile, bearer: tok}, nil + } + + sess, err := session.Load() + if err != nil { + if errors.Is(err, session.ErrNotLoggedIn) { + return credential{}, notSignedIn() + } + return credential{}, exitf(exitAuth, "could not read your session (%v): run nan auth login", err) + } + switch { + case sess.PlatformToken != "": + return credential{kind: credStoredToken, bearer: sess.PlatformToken}, nil + case sess.Token != "": + return credential{kind: credSession, session: sess.Token}, nil + case sess.APIKey != "": + return credential{kind: credStoredKey, bearer: sess.APIKey}, nil + } + return credential{}, notSignedIn() +} + +func notSignedIn() *ExitError { + return exitf(exitAuth, "not signed in: run nan auth login, or set %s (see: nan run --help)", tokenEnvVar) +} + +// readTokenFile reads the token from the first line of path. +// +// A token file that other users can read hands them the token, and one they +// can write lets them swap in their own, so that runs (and their prompts) go +// to an account they control. Like ssh with a private key, such a file is +// refused rather than used. Only regular files are checked: a pipe, such as +// the one `--token-file <(vault read ...)` hands over, belongs to the process +// that made it and has no mode worth reading. +func readTokenFile(path string) (string, error) { + shown := runs.SanitizeLine(path) + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return "", exitf(exitAuth, "--token-file %s does not exist", shown) + } + // The *PathError would repeat the path unsanitised; its cause is enough. + if err != nil { + return "", exitf(exitAuth, "could not open --token-file %s: %v", shown, unwrapPath(err)) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return "", exitf(exitAuth, "could not read --token-file %s: %v", shown, unwrapPath(err)) + } + if info.IsDir() { + return "", exitf(exitAuth, "--token-file %s is a directory, not a file", shown) + } + if perm := info.Mode().Perm(); runtime.GOOS != "windows" && info.Mode().IsRegular() && perm&0o077 != 0 { + return "", exitf(exitAuth, "--token-file %s can be read or changed by other users (mode %04o); make it private with: chmod 600 %s", shown, perm, shown) + } + line, err := bufio.NewReader(io.LimitReader(f, maxTokenLen+2)).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", exitf(exitAuth, "could not read --token-file %s: %v", shown, unwrapPath(err)) + } + tok := strings.TrimSpace(line) + if tok == "" { + return "", exitf(exitAuth, "--token-file %s is empty: put the token on its first line", shown) + } + if !validTokenShape(tok) { + return "", exitf(exitAuth, "the first line of --token-file %s is not a valid token: %s", shown, tokenShapeHint) + } + return tok, nil +} + +// unwrapPath drops the path an *os.PathError carries, which error messages +// here print sanitised on their own. +func unwrapPath(err error) error { + var pe *os.PathError + if errors.As(err, &pe) { + return pe.Err + } + return err +} + +// requireSession is for the commands that only a signed-in session can use. +// The platform refuses tokens and API keys there, and a bare 401 would leave +// a member who set one up wondering why it does not work. +func requireSession(command string) (*session.Session, error) { + sess, err := session.Load() + if err != nil && !errors.Is(err, session.ErrNotLoggedIn) { + return nil, err + } + if sess != nil && sess.Token != "" { + return sess, nil + } + hasToken := os.Getenv(tokenEnvVar) != "" || (sess != nil && (sess.PlatformToken != "" || sess.APIKey != "")) + if hasToken { + return nil, fmt.Errorf("%s needs a session: run nan auth login (platform tokens and API keys only work for nan run and nan runs)", command) + } + return nil, session.ErrNotLoggedIn +} diff --git a/cmd/credentials_test.go b/cmd/credentials_test.go new file mode 100644 index 0000000..f0b215b --- /dev/null +++ b/cmd/credentials_test.go @@ -0,0 +1,455 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +const ( + testPAT = "nan_pat_0123456789abcdefSECRET" + testKey = "sk-0123456789SECRETkey" +) + +// fakeHome points the session at an empty temp directory, optionally with a +// session.json, and clears NAN_TOKEN so the developer's own does not leak in. +func fakeHome(t *testing.T, sessionJSON string) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv(tokenEnvVar, "") + if sessionJSON != "" { + dir := filepath.Join(home, ".config", "nan") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "session.json"), []byte(sessionJSON), 0o600); err != nil { + t.Fatal(err) + } + } + return home +} + +func writeTokenFile(t *testing.T, content string, mode os.FileMode) string { + t.Helper() + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + return path +} + +func envOf(vals map[string]string) func(string) string { + return func(k string) string { return vals[k] } +} + +func TestValidTokenShape(t *testing.T) { + for tok, want := range map[string]bool{ + testPAT: true, + testKey: true, + "nan_pat_": false, + "sk-": false, + "nan_session_abc": false, + "Bearer " + testPAT: false, + "nan_pat_a\r\nX-Evil: 1": false, + "nan_pat_a b": false, + "nan_pat_" + strings.Repeat("a", maxTokenLen): false, + "": false, + } { + if got := validTokenShape(tok); got != want { + t.Errorf("validTokenShape(%q) = %v, want %v", tok, got, want) + } + } +} + +// The documented precedence: NAN_TOKEN > --token-file > saved platform +// token > session > saved API key. +func TestCredentialPrecedence(t *testing.T) { + full := `{"token":"sess-1","platformToken":"` + testPAT + `","apiKey":"` + testKey + `"}` + file := writeTokenFile(t, "nan_pat_fromfile\n", 0o600) + + cases := []struct { + name string + session string + env string + file string + kind credKind + wantSess string + bearer string + }{ + {name: "env wins over everything", session: full, env: "nan_pat_fromenv", file: file, kind: credEnv, bearer: "nan_pat_fromenv"}, + {name: "env is trimmed", session: full, env: " nan_pat_fromenv\n", kind: credEnv, bearer: "nan_pat_fromenv"}, + {name: "file wins over the session", session: full, file: file, kind: credFile, bearer: "nan_pat_fromfile"}, + {name: "file works with no session at all", file: file, kind: credFile, bearer: "nan_pat_fromfile"}, + {name: "saved platform token over the session", session: full, kind: credStoredToken, bearer: testPAT}, + {name: "session over the saved key", session: `{"token":"sess-1","apiKey":"` + testKey + `"}`, kind: credSession, wantSess: "sess-1"}, + {name: "saved platform token over saved key", session: `{"token":"","platformToken":"` + testPAT + `","apiKey":"` + testKey + `"}`, kind: credStoredToken, bearer: testPAT}, + {name: "saved key last", session: `{"token":"","apiKey":"` + testKey + `"}`, kind: credStoredKey, bearer: testKey}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fakeHome(t, c.session) + cred, err := resolveCredential(envOf(map[string]string{tokenEnvVar: c.env}), c.file) + if err != nil { + t.Fatal(err) + } + if cred.kind != c.kind || cred.bearer != c.bearer || cred.session != c.wantSess { + t.Errorf("got kind %d bearer %q session %q", cred.kind, cred.bearer, cred.session) + } + }) + } +} + +func TestNothingConfiguredSaysHowToSignIn(t *testing.T) { + for _, sess := range []string{"", `{"token":""}`} { + fakeHome(t, sess) + _, err := resolveCredential(envOf(nil), "") + if exitCode(err) != exitAuth { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + for _, want := range []string{"nan auth login", tokenEnvVar} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not mention %s", err, want) + } + } + } +} + +// A malformed token is refused before anything is sent, and the error never +// repeats its value. +func TestMalformedTokensAreRefusedWithoutEchoing(t *testing.T) { + const bad = "ghp_SECRETnotours" + fakeHome(t, "") + _, err := resolveCredential(envOf(map[string]string{tokenEnvVar: bad}), "") + if exitCode(err) != exitAuth || !strings.Contains(err.Error(), tokenEnvVar) { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + if strings.Contains(err.Error(), "SECRET") { + t.Errorf("error echoes the value: %v", err) + } + + path := writeTokenFile(t, bad+"\n", 0o600) + _, err = resolveCredential(envOf(nil), path) + if exitCode(err) != exitAuth || strings.Contains(err.Error(), "SECRET") { + t.Errorf("exit %d (%v)", exitCode(err), err) + } +} + +func TestTokenFileReadsTheFirstLine(t *testing.T) { + for name, content := range map[string]string{ + "newline": testPAT + "\n", + "no newline": testPAT, + "crlf": testPAT + "\r\n", + "padded": " " + testPAT + " \n", + "trailing junk": testPAT + "\nsomething else\n", + } { + t.Run(name, func(t *testing.T) { + got, err := readTokenFile(writeTokenFile(t, content, 0o600)) + if err != nil { + t.Fatal(err) + } + if got != testPAT { + t.Errorf("got %q", got) + } + }) + } +} + +func TestTokenFileErrors(t *testing.T) { + dir := t.TempDir() + for name, c := range map[string]struct { + path string + want string + }{ + "missing": {filepath.Join(dir, "nope"), "does not exist"}, + "directory": {dir, "directory"}, + "empty": {writeTokenFile(t, "\n", 0o600), "empty"}, + "too long": {writeTokenFile(t, "nan_pat_"+strings.Repeat("a", 4096), 0o600), "not a valid token"}, + "control characters in a missing path": {filepath.Join(dir, "x\x1b[31m\r\x07"), "does not exist"}, + } { + t.Run(name, func(t *testing.T) { + _, err := readTokenFile(c.path) + if exitCode(err) != exitAuth || !strings.Contains(err.Error(), c.want) { + t.Errorf("exit %d (%v), want %q", exitCode(err), err, c.want) + } + assertNoControl(t, err.Error()) + }) + } +} + +// The path is the member's own, but it is still printed: escape sequences in +// it must not reach the terminal, on any branch. +func TestTokenFilePathIsSanitised(t *testing.T) { + const evil = "tok\x1b]0;pwned\x07\r\u009b" + dir := t.TempDir() + if runtime.GOOS != "windows" { + path := filepath.Join(dir, evil) + if err := os.WriteFile(path, []byte(testPAT), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + _, err := readTokenFile(path) + if err == nil || !strings.Contains(err.Error(), "chmod 600") { + t.Fatalf("err = %v", err) + } + assertNoControl(t, err.Error()) + + empty := filepath.Join(dir, evil+"empty") + if err := os.WriteFile(empty, nil, 0o600); err != nil { + t.Fatal(err) + } + _, err = readTokenFile(empty) + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("err = %v", err) + } + assertNoControl(t, err.Error()) + } + if runtime.GOOS != "windows" { + // A path under a regular file fails to open with ENOTDIR, which is + // not "does not exist": the branch where *os.PathError would carry + // the raw path into the message. + parent := filepath.Join(dir, "plain") + if err := os.WriteFile(parent, nil, 0o600); err != nil { + t.Fatal(err) + } + _, err := readTokenFile(filepath.Join(parent, evil)) + if err == nil || !strings.Contains(err.Error(), "could not open") { + t.Fatalf("err = %v", err) + } + assertNoControl(t, err.Error()) + } + assertNoControl(t, credential{kind: credFile, path: "/tmp/" + evil}.describe()) + assertNoControl(t, authError(credential{kind: credFile, path: "/tmp/" + evil}).Error()) +} + +func assertNoControl(t *testing.T, s string) { + t.Helper() + if strings.ContainsAny(s, "\x1b\x07\r\u009b") { + t.Errorf("control characters reach the terminal: %q", s) + } +} + +// Like ssh with a private key: a token file others can read or write is +// refused, with the command that fixes it. +func TestTokenFileMustBePrivate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mode bits are not the mechanism on Windows") + } + for _, mode := range []os.FileMode{0o644, 0o640, 0o604, 0o620, 0o602, 0o666} { + path := writeTokenFile(t, testPAT+"\n", mode) + _, err := readTokenFile(path) + if exitCode(err) != exitAuth || !strings.Contains(err.Error(), "chmod 600") { + t.Errorf("mode %04o: exit %d (%v)", mode, exitCode(err), err) + } + if err != nil && strings.Contains(err.Error(), "SECRET") { + t.Errorf("mode %04o: error echoes the token: %v", mode, err) + } + } + for _, mode := range []os.FileMode{0o600, 0o400} { + if _, err := readTokenFile(writeTokenFile(t, testPAT+"\n", mode)); err != nil { + t.Errorf("mode %04o refused: %v", mode, err) + } + } +} + +// What the runs commands send is a bearer token for every source but the +// session, and the session cookie for that one. +func TestCredentialIsSentAsBearer(t *testing.T) { + var mu sync.Mutex + var auth, cookie string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + auth, cookie = r.Header.Get("Authorization"), r.Header.Get("Cookie") + mu.Unlock() + _, _ = io.WriteString(w, `{"object":"list","data":[],"next_cursor":null}`) + })) + defer srv.Close() + old := runsBaseURL + runsBaseURL = srv.URL + defer func() { runsBaseURL = old }() + + for _, c := range []struct { + cred credential + auth, cook string + }{ + {credential{kind: credEnv, bearer: testPAT}, "Bearer " + testPAT, ""}, + {credential{kind: credFile, bearer: testKey}, "Bearer " + testKey, ""}, + {credential{kind: credSession, session: "sess-1"}, "", "nan_session=sess-1"}, + } { + if _, err := c.cred.client().ListRuns(context.Background(), apiListOne()); err != nil { + t.Fatal(err) + } + mu.Lock() + if auth != c.auth || cookie != c.cook { + t.Errorf("kind %d sent Authorization %q Cookie %q", c.cred.kind, auth, cookie) + } + mu.Unlock() + } +} + +// A refused token names where it came from and how to replace it, never +// what it is. +func TestRefusedTokenNamesItsSource(t *testing.T) { + for _, c := range []struct { + cred credential + want []string + }{ + {credential{kind: credEnv, bearer: testPAT}, []string{tokenEnvVar, "Settings > Tokens"}}, + {credential{kind: credFile, path: "/run/secrets/nan", bearer: testPAT}, []string{"/run/secrets/nan", "Settings > Tokens"}}, + {credential{kind: credStoredToken, bearer: testPAT}, []string{"saved platform token", "nan auth login --api-token"}}, + {credential{kind: credStoredKey, bearer: testKey}, []string{"saved API key", "nan auth login"}}, + {credential{kind: credSession, session: "s"}, []string{"session has expired", "nan auth login"}}, + } { + err := authError(c.cred) + if err.Code != exitAuth { + t.Errorf("exit %d", err.Code) + } + for _, w := range c.want { + if !strings.Contains(err.Error(), w) { + t.Errorf("%q does not mention %q", err, w) + } + } + if strings.Contains(err.Error(), "SECRET") { + t.Errorf("error echoes the credential: %v", err) + } + } +} + +// End to end through the command line: NAN_TOKEN alone is enough for +// `nan runs ls`, with no session file on the machine. +func TestRunsLsWithOnlyNANTOKEN(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + _, _ = io.WriteString(w, `{"object":"list","data":[],"next_cursor":null}`) + })) + defer srv.Close() + old := runsBaseURL + runsBaseURL = srv.URL + defer func() { runsBaseURL = old }() + fakeHome(t, "") + t.Setenv(tokenEnvVar, testPAT) + + env, err := newRunsEnv() + if err != nil { + t.Fatal(err) + } + env.stdout, env.stderr = io.Discard, io.Discard + if err := doRunsLs(context.Background(), env); err != nil { + t.Fatal(err) + } + if got != "Bearer "+testPAT { + t.Errorf("Authorization = %q", got) + } + if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".config", "nan", "session.json")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("NAN_TOKEN was written to disk (stat: %v)", err) + } +} + +// `nan me` and `nan metrics usage` need a session; with only a token they +// say so instead of a bare 401. +func TestSessionCommandsExplainTokens(t *testing.T) { + for name, setup := range map[string]func(t *testing.T){ + "env": func(t *testing.T) { fakeHome(t, ""); t.Setenv(tokenEnvVar, testPAT) }, + "saved token": func(t *testing.T) { fakeHome(t, `{"token":"","platformToken":"`+testPAT+`"}`) }, + "saved key": func(t *testing.T) { fakeHome(t, `{"token":"","apiKey":"`+testKey+`"}`) }, + } { + t.Run(name, func(t *testing.T) { + setup(t) + _, err := requireSession("nan me") + if err == nil { + t.Fatal("no error") + } + for _, w := range []string{"nan me needs a session", "nan auth login", "nan run"} { + if !strings.Contains(err.Error(), w) { + t.Errorf("%q does not mention %q", err, w) + } + } + if strings.Contains(err.Error(), "SECRET") { + t.Errorf("error echoes the token: %v", err) + } + }) + } + + fakeHome(t, "") + if _, err := requireSession("nan me"); !strings.Contains(fmtErr(err), "not logged in") { + t.Errorf("nothing configured: %v", err) + } + fakeHome(t, `{"token":"sess-1"}`) + if sess, err := requireSession("nan me"); err != nil || sess.Token != "sess-1" { + t.Errorf("a session is refused: %v", err) + } +} + +func fmtErr(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func apiListOne() api.ListRunsParams { return api.ListRunsParams{Limit: 1} } + +// Through the command line: --token-file reaches the request. +func TestRunsLsWithTokenFileFlag(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + _, _ = io.WriteString(w, `{"object":"list","data":[],"next_cursor":null}`) + })) + defer srv.Close() + old := runsBaseURL + runsBaseURL = srv.URL + defer func() { runsBaseURL = old }() + fakeHome(t, `{"token":"sess-1"}`) + path := writeTokenFile(t, testPAT+"\n", 0o600) + defer func() { tokenFileFlag = "" }() + + rootCmd.SetArgs([]string{"runs", "ls", "--token-file", path}) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + if err := rootCmd.Execute(); err != nil { + t.Fatal(err) + } + if got != "Bearer "+testPAT { + t.Errorf("Authorization = %q", got) + } +} + +func TestUnwrapPathDropsOnlyThePath(t *testing.T) { + cause := errors.New("not a directory") + if got := unwrapPath(&os.PathError{Op: "open", Path: "/secret\x1b/path", Err: cause}); got != cause { + t.Errorf("PathError: got %v", got) + } + wrapped := fmt.Errorf("ctx: %w", &os.PathError{Op: "read", Path: "/p", Err: cause}) + if got := unwrapPath(wrapped); got != cause { + t.Errorf("wrapped PathError: got %v", got) + } + other := errors.New("other") + if got := unwrapPath(other); got != other { + t.Errorf("other error changed: %v", got) + } +} + +func TestDescribeSession(t *testing.T) { + if got := (credential{kind: credSession}).describe(); got != "your session" { + t.Errorf("describe = %q", got) + } +} diff --git a/cmd/exit.go b/cmd/exit.go index f9ba4dd..0ca9c52 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -48,11 +48,15 @@ func usageErrorf(format string, args ...any) *ExitError { return exitf(exitUsage, format, args...) } -// authError is a refused or missing credential, with the one command that -// fixes it. -func authError(usingKey bool) *ExitError { - if usingKey { - return exitf(exitAuth, "nan.builders refused your API key — run: nan auth login") +// authError is a refused credential, named (never shown) with what fixes it. +func authError(cred credential) *ExitError { + switch cred.kind { + case credEnv, credFile: + return exitf(exitAuth, "nan.builders refused %s: it may be revoked or expired; create a new one in Settings > Tokens at https://cloud.nan.builders", cred.describe()) + case credStoredToken: + return exitf(exitAuth, "nan.builders refused %s. Save a new one with: nan auth login --api-token. While a token is saved, it is used before your email session; nan auth logout removes it", cred.describe()) + case credStoredKey: + return exitf(exitAuth, "nan.builders refused %s: run: nan auth login", cred.describe()) } return exitf(exitAuth, "%v — run: nan auth login", api.ErrSessionExpired) } @@ -60,13 +64,13 @@ func authError(usingKey bool) *ExitError { // apiExit maps a failed request onto an exit code and a message that says // what to do next. The platform's own message is guest-adjacent text too, so // it is sanitised like everything else. -func apiExit(err error, usingKey bool) *ExitError { +func apiExit(err error, cred credential) *ExitError { var exit *ExitError if errors.As(err, &exit) { return exit } if errors.Is(err, api.ErrSessionExpired) { - return authError(usingKey) + return authError(cred) } if errors.Is(err, session.ErrNotLoggedIn) { return &ExitError{Code: exitAuth, Err: err} diff --git a/cmd/help_test.go b/cmd/help_test.go new file mode 100644 index 0000000..7aab40c --- /dev/null +++ b/cmd/help_test.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "strings" + "testing" +) + +const runsDocs = "https://nan.builders/docs/runs" + +// The help is where a member learns what a run does to their repository and +// how to use the CLI with nobody at the keyboard. These are the facts it has +// to carry. +func TestRunHelpCarriesTheEssentials(t *testing.T) { + help := runCmd.Long + "\n" + runCmd.Example + for _, want := range []string{ + "nan-run/", + "never", // never pushes + "checkout is untouched", + "--worktree", "fails outside a repository", + "--no-worktree", + "waits as queued", + "Folder = --cwd", "Separate branch = --worktree/--no-worktree", "Time limit = --timeout", "Task = the prompt", + "NAN_TOKEN", "--token-file PATH", "nan auth login", "nan auth login --api-token", + "Settings > Tokens", + runsDocs, + } { + if !strings.Contains(help, want) { + t.Errorf("nan run --help does not mention %q", want) + } + } +} + +func TestRunsHelpLinksTheDocs(t *testing.T) { + if !strings.Contains(runsCmd.Long, runsDocs) || !strings.Contains(runsCmd.Long, "NAN_TOKEN") { + t.Errorf("nan runs --help: %q", runsCmd.Long) + } + for _, want := range []string{"tool_call", "tool_result", "message", "finished", "--json", "one event per line", runsDocs} { + if !strings.Contains(runsLogsCmd.Long, want) { + t.Errorf("nan runs logs --help does not mention %q", want) + } + } +} + +// House style for new help text: no em-dashes. +func TestNewHelpHasNoEmDashes(t *testing.T) { + for name, text := range map[string]string{ + "run": runCmd.Long + runCmd.Example, + "runs": runsCmd.Long, + "runs logs": runsLogsCmd.Long, + "auth login": loginCmd.Long + loginCmd.Example, + "token-file": tokenFileUsage, + } { + if strings.Contains(text, "—") { + t.Errorf("%s help has an em-dash", name) + } + } +} + +// --token-file is accepted by nan run and by every nan runs subcommand. +func TestTokenFileFlagIsOnEveryRunsCommand(t *testing.T) { + if runCmd.Flags().Lookup("token-file") == nil { + t.Error("nan run has no --token-file") + } + for _, c := range runsCmd.Commands() { + if c.InheritedFlags().Lookup("token-file") == nil && c.Flags().Lookup("token-file") == nil { + t.Errorf("nan runs %s has no --token-file", c.Name()) + } + } +} diff --git a/cmd/me.go b/cmd/me.go index ba46545..1406517 100644 --- a/cmd/me.go +++ b/cmd/me.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/nxssie/nan-cli/internal/api" - "github.com/nxssie/nan-cli/internal/session" "github.com/spf13/cobra" ) @@ -20,7 +19,7 @@ func init() { } func runMe(cmd *cobra.Command, args []string) error { - sess, err := session.Load() + sess, err := requireSession("nan me") if err != nil { return err } diff --git a/cmd/metrics.go b/cmd/metrics.go index 9725ba8..33302e1 100644 --- a/cmd/metrics.go +++ b/cmd/metrics.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/nxssie/nan-cli/internal/api" - "github.com/nxssie/nan-cli/internal/session" "github.com/spf13/cobra" ) @@ -26,7 +25,7 @@ func init() { } func runMetricsUsage(cmd *cobra.Command, args []string) error { - sess, err := session.Load() + sess, err := requireSession("nan metrics usage") if err != nil { return err } diff --git a/cmd/review_test.go b/cmd/review_test.go index 59e2b84..f649769 100644 --- a/cmd/review_test.go +++ b/cmd/review_test.go @@ -68,7 +68,10 @@ func TestCredentialsNeverReachTheOutput(t *testing.T) { h := newHarness(t, fake) c := api.NewRunsClient(creds[0], creds[1]).WithBaseURL(h.url) c.Backoff = func(int) time.Duration { return time.Millisecond } - h.env.client, h.env.usingKey = c, creds[0] == "" + h.env.client = c + if creds[0] == "" { + h.env.cred = credential{kind: credStoredKey, bearer: creds[1]} + } for _, detach := range []bool{false, true} { opts := defaultOpts() opts.detach = detach diff --git a/cmd/root.go b/cmd/root.go index db7a060..1eba075 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,10 +23,14 @@ var rootCmd = &cobra.Command{ DisableDefaultCmd: true, }, RunE: func(cmd *cobra.Command, args []string) error { - return tui.Run() + return openTUI() }, } +// openTUI is the dashboard. A variable so a test can tell whether a command +// line reached it. +var openTUI = tui.Run + func init() { // SilenceUsage covers runtime failures, but a mistyped flag IS a usage // mistake and the flag list is the answer to it. @@ -35,10 +39,10 @@ func init() { return err }) - // The same wordmark the About tab draws, so `--version` and the panel are - // recognisably the same program. This one is framed and stacked, which a - // command that prints once and exits can afford and a tab cannot. - rootCmd.SetVersionTemplate("\n" + tui.WelcomeStacked(" ") + "\n") + // One line a script can parse (`nan --version | cut -d' ' -f2`). The + // framed wordmark this used to print filled the screen like the dashboard + // does, and read as the dashboard having opened. + rootCmd.SetVersionTemplate("nan {{.Version}}\n") } func Execute() { diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..29b4d15 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/nxssie/nan-cli/internal/tui" +) + +// `nan --version` and `nan -v` print one line and exit 0, without opening +// the dashboard. +func TestVersionPrintsOneLine(t *testing.T) { + old := openTUI + defer func() { openTUI = old }() + openTUI = func() error { + t.Error("--version opened the dashboard") + return nil + } + + for _, flag := range []string{"--version", "-v"} { + var out bytes.Buffer + rootCmd.SetArgs([]string{flag}) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("%s: %v", flag, err) + } + if got, want := out.String(), "nan "+tui.Version+"\n"; got != want { + t.Errorf("%s printed %q, want %q", flag, got, want) + } + _ = rootCmd.Flags().Set("version", "false") + } +} diff --git a/cmd/run.go b/cmd/run.go index f886e49..17bd7a2 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -14,7 +14,6 @@ import ( "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/runs" - "github.com/nxssie/nan-cli/internal/session" "github.com/spf13/cobra" ) @@ -61,9 +60,39 @@ The prompt is the argument, the contents of -f FILE, or stdin when the argument is "-". The run happens in the workspace, not on this machine: it goes on if this command stops, and its log stays on nan.builders. -Ctrl-C detaches and leaves the run going. A second Ctrl-C within 2 seconds -cancels it. Scripts that may retry should pass --idempotency-key, so a -retry after a lost connection returns the run instead of starting another. +Git branch: + By default (auto) a run inside a git repository works on its own branch, + nan-run/, and commits there. It never + pushes, and your checkout is untouched. Outside a repository it works in + the folder itself. + --worktree always use a separate branch (fails outside a repository) + --no-worktree work in the folder itself, even inside a repository + +Queue: + If the workspace is already running as many runs as its size allows, the + run waits as queued and starts when one of them ends. + +In the portal: + Folder = --cwd, Separate branch = --worktree/--no-worktree, + Time limit = --timeout, Task = the prompt. + +Authentication (the first one found is used): + 1. NAN_TOKEN env var with a platform token (nan_pat_...) or an + API key (sk-...); for CI, set it from a secret + 2. --token-file PATH the token on the first line of a file only you can + read (chmod 600) + 3. a saved token from: nan auth login --api-token (reads stdin) + 4. your session from: nan auth login + 5. a saved API key from the dashboard's Setup tab, or an sk- key + saved with nan auth login --api-token + Create platform tokens in Settings > Tokens at https://cloud.nan.builders. + Never pass a token as an argument: it would end up in your shell history. + +Detaching: + Ctrl-C detaches and leaves the run going. A second Ctrl-C within 2 + seconds cancels it. Scripts that may retry should pass --idempotency-key, + so a retry after a lost connection returns the run instead of starting + another. A prompt given as an argument ends up in your shell history and in ps; for anything sensitive use -f FILE or "-". @@ -78,11 +107,14 @@ Exit codes: 64 usage error 65 not signed in, or not allowed 69 nan.builders unavailable, or the stream was lost (the run goes on) - 75 detached: the run was accepted and is still going`, + 75 detached: the run was accepted and is still going + +Docs: https://nan.builders/docs/runs`, Example: ` nan run "review PR 42 and write the findings to artifacts/review.md" nan run --ws develop --cwd /home/nan/projects/api -f task.md git diff | nan run - - id=$(nan run --detach "upgrade the dependencies")`, + id=$(nan run --detach "upgrade the dependencies") + nan run --ws ci -f task.md # in CI, with NAN_TOKEN set from a secret`, Args: cobra.ArbitraryArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { @@ -108,14 +140,15 @@ func init() { f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") f.StringVar(&runOpts.agent, "agent", "pi", "Agent to run: pi or hermes") f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own)") - f.StringVar(&runOpts.cwd, "cwd", "", "Directory in the workspace to run in (default: /home/nan)") - f.BoolVar(&runOpts.worktree, "worktree", false, "Always run in a separate git worktree") - f.BoolVar(&runOpts.noWorktree, "no-worktree", false, "Run in the directory itself, even inside a git repository") + f.StringVar(&runOpts.cwd, "cwd", "", "Folder in the workspace to run in (default: /home/nan)") + f.BoolVar(&runOpts.worktree, "worktree", false, "Always work on a separate branch, nan-run/ (fails outside a git repository)") + f.BoolVar(&runOpts.noWorktree, "no-worktree", false, "Work in the folder itself, even inside a git repository") f.DurationVar(&runOpts.timeout, "timeout", 30*time.Minute, "Stop the run after this long, 1m to 2h (default 30m)") f.BoolVar(&runOpts.detach, "detach", false, "Queue the run, print its id and return without streaming") f.BoolVar(&runOpts.json, "json", false, "Print events as JSON lines, then the finished run (with --detach: the run)") f.StringVar(&runOpts.idempotencyKey, "idempotency-key", "", "Retry-safe key: the same key and request return the run already created") f.StringVarP(&runOpts.file, "file", "f", "", `Read the prompt from FILE ("-" for stdin)`) + f.StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) // cobra would print the default as "30m0s"; the usage line says 30m. f.Lookup("timeout").DefValue = "0s" } @@ -124,28 +157,22 @@ func init() { // tests can hand them a mock server, buffers and a fake Ctrl-C. type runsEnv struct { client *api.RunsClient - usingKey bool + cred credential stdout io.Writer stderr io.Writer interrupts <-chan os.Signal } func newRunsEnv() (*runsEnv, error) { - sess, err := session.Load() + cred, err := resolveCredential(os.Getenv, tokenFileFlag) if err != nil { - if errors.Is(err, session.ErrNotLoggedIn) { - return nil, &ExitError{Code: exitAuth, Err: err} - } - return nil, exitf(exitAuth, "could not read your session (%v) — run: nan auth login", err) - } - if sess.Token == "" && sess.APIKey == "" { - return nil, &ExitError{Code: exitAuth, Err: session.ErrNotLoggedIn} + return nil, err } return &runsEnv{ - client: api.NewRunsClient(sess.Token, sess.APIKey), - usingKey: sess.Token == "", - stdout: os.Stdout, - stderr: os.Stderr, + client: cred.client(), + cred: cred, + stdout: os.Stdout, + stderr: os.Stderr, }, nil } @@ -167,7 +194,7 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if errors.As(err, &apiErr) && apiErr.Code == "workspace_not_running" { return exitf(exitUnavailable, "workspace %q is not running — start it at https://cloud.nan.builders and try again", runs.SanitizeLine(req.Workspace)) } - return apiExit(err, env.usingKey) + return apiExit(err, env.cred) } if !runIDPattern.MatchString(run.ID) { return exitf(exitUnavailable, "nan.builders answered with something that is not a run id") @@ -312,7 +339,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d defer cancel() run, changed, err := env.client.CancelRun(cancelCtx, id) if err != nil { - return apiExit(err, env.usingKey) + return apiExit(err, env.cred) } if !changed { fmt.Fprintf(env.stderr, "run %s had already ended: %s\n", safeID, runs.SanitizeLine(run.State)) @@ -328,7 +355,7 @@ func streamExit(err error, env *runsEnv, id string) error { if errors.Is(err, api.ErrStreamLost) { return exitf(exitUnavailable, "%s — run %s goes on in the workspace; follow it with: nan runs logs %s -f", runs.SanitizeLine(err.Error()), safeID, safeID) } - exit := notFound(err, safeID, env.usingKey) + exit := notFound(err, safeID, env.cred) if exit.Code == exitUnavailable { return exitf(exitUnavailable, "%s (run %s)", runs.SanitizeLine(exit.Error()), safeID) } @@ -470,7 +497,7 @@ func readPrompt(in promptInput, opts runOptions, args []string) (string, error) func defaultWorkspace(ctx context.Context, env *runsEnv) (string, error) { list, err := env.client.ListWorkspaces(ctx) if err != nil { - exit := apiExit(err, env.usingKey) + exit := apiExit(err, env.cred) if exit.Code == exitAuth { return "", exit } diff --git a/cmd/run_test.go b/cmd/run_test.go index faa5c63..8f0b798 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -386,6 +386,7 @@ func TestExpiredSessionExits65(t *testing.T) { } func TestNoSessionExits65(t *testing.T) { + t.Setenv(tokenEnvVar, "") t.Setenv("HOME", t.TempDir()) t.Setenv("USERPROFILE", os.Getenv("HOME")) _, err := newRunsEnv() @@ -395,6 +396,7 @@ func TestNoSessionExits65(t *testing.T) { } func TestAPIKeyIsUsedWithoutASession(t *testing.T) { + t.Setenv(tokenEnvVar, "") home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) @@ -408,7 +410,7 @@ func TestAPIKeyIsUsedWithoutASession(t *testing.T) { if err != nil { t.Fatal(err) } - if !env.usingKey { + if env.cred.kind != credStoredKey || env.cred.bearer != "sk-member" { t.Error("a session with only an API key does not use it") } } diff --git a/cmd/runs.go b/cmd/runs.go index 043f2ae..b8b13cb 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -32,8 +32,15 @@ var ( ) var runsCmd = &cobra.Command{ - Use: "runs", - Short: "List, inspect, follow and cancel agent runs", + Use: "runs", + Short: "List, inspect, follow and cancel agent runs", + Long: `List, inspect, follow and cancel the agent runs started with nan run, from +the portal, or by your automations. These commands authenticate the same +way as nan run: NAN_TOKEN, --token-file PATH, a token saved with nan auth +login --api-token, or your nan auth login session (the full order is in +nan run --help). + +Docs: https://nan.builders/docs/runs`, Annotations: sysexits, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -70,11 +77,20 @@ var runsShowCmd = &cobra.Command{ var runsLogsCmd = &cobra.Command{ Use: "logs ", Short: "Print a run's events (-f to follow until it ends)", - Long: `Print a run's events. + Long: `Print a run's events. Their types: started, message (what the agent +says), tool_call and tool_result, log (agent stdout and stderr), artifact, +error, truncated (output was cut), and finished. With -f, follow the run until it ends and exit with its code, the same codes as nan run: 0 succeeded, 1 failed, 2 timed out, 3 cancelled, 4 workspace -not set up for it. Ctrl-C stops following; the run goes on.`, +not set up for it. Ctrl-C stops following; the run goes on. + +With --json, print one event per line as a JSON object (seq, ts, type, +data), then, with -f, the finished run. The event types and their data are +described in the docs. --after N starts after event number N, to pick up +where an earlier read stopped. + +Docs: https://nan.builders/docs/runs`, Args: oneRunID, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { @@ -95,6 +111,7 @@ var runsCancelCmd = &cobra.Command{ func init() { rootCmd.AddCommand(runsCmd) runsCmd.AddCommand(runsLsCmd, runsShowCmd, runsLogsCmd, runsCancelCmd) + runsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) runsLsCmd.Flags().StringVar(&lsWorkspace, "ws", "", "Only runs in this workspace") runsLsCmd.Flags().StringVar(&lsState, "state", "", "Only runs in this state: queued, starting, running, succeeded, failed, cancelled, timed_out") @@ -150,12 +167,12 @@ func oneRunID(cmd *cobra.Command, args []string) error { return checkRunID(args[0]) } -func notFound(err error, id string, usingKey bool) *ExitError { +func notFound(err error, id string, cred credential) *ExitError { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.Status == 404 { return usageErrorf("run %s not found", id) } - return apiExit(err, usingKey) + return apiExit(err, cred) } var validStates = map[string]bool{ @@ -176,7 +193,7 @@ func checkLsFlags() error { func doRunsLs(ctx context.Context, env *runsEnv) error { list, err := env.client.ListRuns(ctx, api.ListRunsParams{Workspace: lsWorkspace, State: lsState, Limit: lsLimit}) if err != nil { - return apiExit(err, env.usingKey) + return apiExit(err, env.cred) } if lsJSON { return printJSON(env.stdout, list, true) @@ -227,7 +244,7 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { } run, err := env.client.GetRun(ctx, id) if err != nil { - return notFound(err, id, env.usingKey) + return notFound(err, id, env.cred) } if showJSON { return printJSON(env.stdout, run, true) @@ -340,7 +357,7 @@ func doRunsLogs(ctx context.Context, env *runsEnv, id string) error { for { page, err := env.client.EventsPage(ctx, id, after, 500) if err != nil { - return notFound(err, id, env.usingKey) + return notFound(err, id, env.cred) } for _, ev := range page.Data { if logsJSON { @@ -370,7 +387,7 @@ func doRunsCancel(ctx context.Context, env *runsEnv, id string) error { } run, changed, err := env.client.CancelRun(ctx, id) if err != nil { - return notFound(err, id, env.usingKey) + return notFound(err, id, env.cred) } if cancelJSON { return printJSON(env.stdout, run, true) diff --git a/go.mod b/go.mod index fe4ee2a..2cf9520 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/term v0.2.2 github.com/spf13/cobra v1.10.2 ) @@ -15,7 +16,6 @@ require ( github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect diff --git a/internal/session/session.go b/internal/session/session.go index a94c20c..1d74920 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -9,11 +9,20 @@ import ( ) type Session struct { - Token string `json:"token"` - APIKey string `json:"apiKey,omitempty"` - EnabledTools map[string]bool `json:"enabledTools,omitempty"` + Token string `json:"token"` + APIKey string `json:"apiKey,omitempty"` + // PlatformToken is a nan_pat_ token saved with `nan auth login + // --api-token`. It is kept apart from APIKey because the Setup tab writes + // APIKey into every tool it configures, and the inference API refuses + // platform tokens. + PlatformToken string `json:"platformToken,omitempty"` + EnabledTools map[string]bool `json:"enabledTools,omitempty"` } +// TokenEnvVar is the environment variable `nan run` and `nan runs` read a +// platform token or an API key from. Nothing writes it to disk. +const TokenEnvVar = "NAN_TOKEN" + var ErrNotLoggedIn = errors.New("not logged in — run: nan auth login") func dir() (string, error) { diff --git a/internal/tui/banner.go b/internal/tui/banner.go index c448a85..90c336b 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -165,41 +165,6 @@ func Welcome(indent string) string { return b.String() } -// WelcomeStacked is the other arrangement: a small label above the wordmark and -// the name under it, the way the Copilot CLI lays its welcome out, instead of -// everything sitting to the right. -func WelcomeStacked(indent string) string { - corner := lipgloss.NewStyle().Foreground(lipgloss.Color(brandVioletDeep)) - name, dim, text := styles() - - const pad = 3 - inner := wordmarkWidth + 2 - body := indent + strings.Repeat(" ", pad) - edge := func(left, right string) string { - return indent + corner.Render(left) + strings.Repeat(" ", inner) + corner.Render(right) - } - - // Right-aligned under the art, like the reference's "Command-line - // interface" under the logo. - under := name.Render("nan.builders") + dim.Render(" · cloud CLI v"+Version) - gap := wordmarkWidth - lipgloss.Width(under) - if gap < 0 { - gap = 0 - } - - var b strings.Builder - b.WriteString(edge("┌─", "─┐") + "\n\n") - b.WriteString(body + dim.Render("welcome to") + "\n") - for _, row := range wordmark { - b.WriteString(body + paint(row) + "\n") - } - b.WriteString(body + strings.Repeat(" ", gap) + under + "\n\n") - b.WriteString(body + text.Render("created by ") + name.Render("@Nxssie") + "\n") - b.WriteString(body + text.Render("maintained by ") + name.Render("Helmcode Team") + "\n") - b.WriteString("\n" + edge("└─", "─┘") + "\n") - return b.String() -} - // ── home ────────────────────────────────────────────────────────────────────── // renderHome is the first thing the panel shows: the wordmark, and how to move diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 86ec1f7..78d62a3 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -113,3 +113,35 @@ func TestASessionThatExpiresLaterDoesNotGrabTheKeyboard(t *testing.T) { t.Error("an expiry mid-session threw the member into the sign-in") } } + +// A platform token does not open the dashboard; the sign-in says so instead +// of asking for an email as if nothing were set up. +func TestSignInExplainsAPlatformToken(t *testing.T) { + t.Setenv("NAN_TOKEN", "") + m := setupModel(t, &session.Session{}) + m.startLogin() + if m.loginMsg != "" { + t.Errorf("a machine with nothing set up gets %q", m.loginMsg) + } + + for name, setup := range map[string]func() *session.Session{ + "saved": func() *session.Session { return &session.Session{PlatformToken: "nan_pat_x"} }, + "env": func() *session.Session { + t.Setenv("NAN_TOKEN", "nan_pat_x") + return &session.Session{} + }, + } { + m := setupModel(t, setup()) + m.lay = newLayout(90, 30) + m.startLogin() + flat := strings.Join(strings.Fields(m.View()), " ") + for _, want := range []string{"nan run and nan runs only", "email sign-in"} { + if !strings.Contains(flat, want) { + t.Errorf("%s: the sign-in does not say %q:\n%s", name, want, m.View()) + } + } + if strings.Contains(flat, "nan_pat_x") { + t.Errorf("%s: the token is on screen", name) + } + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index fca689f..0065a6a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -754,6 +754,12 @@ func (m *model) startLogin() tea.Cmd { m.wizard = wizardEmail m.loginStage = loginAskEmail m.loginMsg = "" + // A machine set up with a platform token looks signed in to whoever set + // it up. The dashboard cannot use one, and asking for an email with no + // word about it reads as the token having been lost. + if m.sess.PlatformToken != "" || os.Getenv(session.TokenEnvVar) != "" { + m.loginMsg = tokenOnlyNotice + } m.loginInput.SetValue("") m.loginInput.Placeholder = "you@example.com" m.loginInput.Prompt = "Email: " @@ -763,6 +769,10 @@ func (m *model) startLogin() tea.Cmd { return m.loginInput.Focus() } +// tokenOnlyNotice is what the sign-in says on a machine that has a platform +// token and no session. +const tokenOnlyNotice = "the platform token on this machine works for nan run and nan runs only; the dashboard needs the email sign-in" + // What step 3 says while the cluster is looking at a key just saved. const keyChecking = "checking it against the cluster…" @@ -3549,7 +3559,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.24" +const Version = "0.1.25" func renderAbout(l layout) string { var b strings.Builder