cli: an unauthenticated command offers to log you in, then continues (#162)
pick's named-console refusals become typed (byte-identical strings); resolveMode on a terminal offers the device flow inline and retries, so the verb the user typed is what runs. Nothing-configured prompts for a URL; scripts and n/EOF keep today's exact errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+225
−9
account_choose.go+18 −0apimode.go+66 −6apimode_test.go+124 −0auth.go+17 −3
diff --git a/account_choose.go b/account_choose.go| index 1f4594be4..fee2f21d8 100644 |
| --- a/account_choose.go |
| +++ b/account_choose.go |
| @@ -49,6 +49,24 @@ var interactiveTerminal = func() bool { |
| // tests for the same reason interactiveTerminal is a var. |
| var chooserIn io.Reader = os.Stdin |
| +// promptLine prints one prompt and reads one trimmed line from chooserIn; |
| +// EOF answers "". The prompts here are one line each on a line-buffered |
| +// terminal, so a fresh Scanner per question does not eat the next answer. |
| +func promptLine(out io.Writer, prompt string) string { |
| + fmt.Fprint(out, prompt) |
| + sc := bufio.NewScanner(chooserIn) |
| + if !sc.Scan() { |
| + return "" |
| + } |
| + return strings.TrimSpace(sc.Text()) |
| +} |
| + |
| +// promptYes asks a y/N question; only y/Y is yes, EOF and everything else |
| +// is no. |
| +func promptYes(out io.Writer, prompt string) bool { |
| + return strings.EqualFold(promptLine(out, prompt), "y") |
| +} |
| + |
| // chooseAccount asks which account an omitted -account means, then — when |
| // saveDest names a file — whether to remember the answer there (the caller |
| // writes the config; this function only asks). An empty saveDest skips the |
diff --git a/apimode.go b/apimode.go| index 2eb5a1b91..14127d18b 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -97,14 +97,20 @@ func resolveMode(consoleFlag string) (*apiSession, error) { |
| // them here would tell someone who has in fact logged in before |
| // that nothing is configured at all. |
| if expired := expiredCredentialFirst(); expired != nil { |
| - return nil, fmt.Errorf("the credential for %s expired — run: carlos auth login -console %s", |
| - expired.Console, expired.Console) |
| + err = ¬LoggedInError{Console: expired.Console, Expired: true, |
| + msg: fmt.Sprintf("the credential for %s expired — run: carlos auth login -console %s", |
| + expired.Console, expired.Console)} |
| + } else { |
| + err = errNoDeploymentTarget |
| } |
| - return nil, fmt.Errorf("no deployment target configured: " + |
| - "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " + |
| - "or run carlos auth login -console <url> to work through a console") |
| } |
| - return nil, err |
| + // A terminal gets the remedy offered, not just named (#162): log in |
| + // right here and CONTINUE the command the user actually typed. |
| + // Scripts, n, and EOF keep the exact errors built above. |
| + cred, err = offerLogin(err, consoleFlag) |
| + if err != nil { |
| + return nil, err |
| + } |
| } |
| return &apiSession{ |
| c: newConsoleClient(cred.Console, cred.Token), |
| @@ -113,6 +119,60 @@ func resolveMode(consoleFlag string) (*apiSession, error) { |
| }, nil |
| } |
| +// errNoDeploymentTarget is resolveMode's "nothing configured at all" |
| +// refusal — a var so offerLogin can recognize it by identity rather than |
| +// by matching its wording. |
| +var errNoDeploymentTarget = errors.New("no deployment target configured: " + |
| + "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " + |
| + "or run carlos auth login -console <url> to work through a console") |
| + |
| +// offerLogin is the interactive half of resolveMode's refusals (#162). On |
| +// a terminal, a refusal that names a console offers to run the device flow |
| +// against it right now; the nothing-configured refusal asks for a URL |
| +// (typing one IS the consent — no second prompt). Everything else — and |
| +// every non-terminal caller — gets the original error back verbatim. The |
| +// login flow itself is authLogin, unchanged: browser approval, per-origin |
| +// credential, no shortcuts. |
| +func offerLogin(origErr error, consoleFlag string) (*credential, error) { |
| + if !interactiveTerminal() { |
| + return nil, origErr |
| + } |
| + var target string |
| + var nle *notLoggedInError |
| + switch { |
| + case errors.As(origErr, &nle): |
| + if nle.Expired { |
| + fmt.Fprintf(stdout, "your login for %s has expired\n", nle.Console) |
| + if !promptYes(stdout, "log in again now? [y/N] ") { |
| + return nil, origErr |
| + } |
| + } else { |
| + fmt.Fprintf(stdout, "not logged in to %s\n", nle.Console) |
| + if !promptYes(stdout, "log in now? [y/N] ") { |
| + return nil, origErr |
| + } |
| + } |
| + target = nle.Console |
| + case errors.Is(origErr, errNoDeploymentTarget): |
| + fmt.Fprintln(stdout, "no console configured and no login found.") |
| + raw := promptLine(stdout, "console URL (e.g. https://console.carloku.com): ") |
| + if raw == "" { |
| + return nil, origErr |
| + } |
| + n, err := normalizeConsole(raw) |
| + if err != nil { |
| + return nil, err |
| + } |
| + target = n |
| + default: |
| + return nil, origErr |
| + } |
| + if err := authLogin(stdout, []string{"-console", target}); err != nil { |
| + return nil, err |
| + } |
| + return loggedInCredential(consoleFlag) |
| +} |
| + |
| // noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for |
| // "no -console given (and no CARLOS_CONSOLE either) and there is nothing |
| // live to pick from" — the one pick() refusal that never mentions direct |
diff --git a/apimode_test.go b/apimode_test.go| index 5b1fd4415..f7702387b 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -114,6 +114,7 @@ func TestResolveModeAPI(t *testing.T) { |
| func TestResolveModeNothing(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + forceTerminal(t, false) // pin: this asserts the NON-interactive refusal |
| sess, err := resolveMode("") |
| if sess != nil { |
| @@ -137,6 +138,7 @@ func TestResolveModeNothing(t *testing.T) { |
| func TestResolveModeExpiredCredentialOnly(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + forceTerminal(t, false) // pin: this asserts the NON-interactive refusal |
| f, _ := loadCredentials() |
| f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)}) |
| @@ -3006,6 +3008,128 @@ func TestDefaultAccountConfigDefault(t *testing.T) { |
| } |
| } |
| +// resolveMode on a terminal offers to run the login it would otherwise |
| +// only name — and then the ORIGINAL command continues (#162). |
| +func TestResolveModeOffersLoginAndContinues(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + captureStdout(t) |
| + forceTerminal(t, true) |
| + fc := newFakeConsole(t) |
| + writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("y\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + |
| + sess, err := resolveMode("") |
| + if err != nil || sess == nil { |
| + t.Fatalf("resolveMode after offered login = %v, %v", sess, err) |
| + } |
| + if sess.console != fc.ts.URL || sess.address != "paul@keymail.dev" { |
| + t.Fatalf("session = %s as %s, want the fake console as paul", sess.console, sess.address) |
| + } |
| +} |
| + |
| +func TestResolveModeOfferDeclinedKeepsTheError(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + captureStdout(t) |
| + forceTerminal(t, true) |
| + fc := newFakeConsole(t) |
| + writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("n\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + |
| + _, err := resolveMode("") |
| + if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) || |
| + !strings.Contains(err.Error(), "carlos auth login -console "+fc.ts.URL) { |
| + t.Fatalf("declined offer = %v, want the original refusal verbatim", err) |
| + } |
| +} |
| + |
| +func TestResolveModeNonTTYErrorsUnchanged(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + captureStdout(t) |
| + forceTerminal(t, false) |
| + fc := newFakeConsole(t) |
| + writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`) |
| + |
| + // No prompt machinery runs at all: scripts get the string they have |
| + // always matched. |
| + _, err := resolveMode("") |
| + if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) { |
| + t.Fatalf("non-TTY = %v, want today's refusal", err) |
| + } |
| + |
| + // And the nothing-configured case keeps ITS exact wording too. |
| + os.RemoveAll(".carlos") |
| + if _, err := resolveMode(""); err == nil || |
| + !strings.Contains(err.Error(), "no deployment target configured") { |
| + t.Fatalf("non-TTY unconfigured = %v", err) |
| + } |
| +} |
| + |
| +func TestResolveModeExpiredOffersRelogin(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + out := captureStdout(t) |
| + forceTerminal(t, true) |
| + fc := newFakeConsole(t) |
| + f, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "stale", |
| + CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(-time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("y\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + |
| + // Only one credential and it is expired — pick's "" branch reaches the |
| + // expired-first rewrite, which must offer a re-login, and the fresh |
| + // credential must carry the NEW token. |
| + sess, err := resolveMode("") |
| + if err != nil || sess == nil || sess.console != fc.ts.URL { |
| + t.Fatalf("expired relogin = %v, %v", sess, err) |
| + } |
| + if !strings.Contains(out.String(), "has expired") { |
| + t.Fatalf("the expiry must be said before the offer:\n%s", out.String()) |
| + } |
| +} |
| + |
| +func TestResolveModeUnconfiguredPromptsForURL(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + captureStdout(t) |
| + forceTerminal(t, true) |
| + fc := newFakeConsole(t) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader(fc.ts.URL + "\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + |
| + // An empty answer keeps the original two-remedies error. (Checked |
| + // FIRST — a successful login below leaves a credential behind, and |
| + // resolveMode would simply use it.) |
| + chooserIn = strings.NewReader("\n") |
| + if _, err := resolveMode(""); err == nil || |
| + !strings.Contains(err.Error(), "no deployment target configured") { |
| + t.Fatalf("empty URL = %v", err) |
| + } |
| + |
| + // Nothing configured anywhere: the URL prompt IS the consent — typing |
| + // one goes straight into the login flow. |
| + chooserIn = strings.NewReader(fc.ts.URL + "\n") |
| + sess, err := resolveMode("") |
| + if err != nil || sess == nil || sess.console != fc.ts.URL { |
| + t.Fatalf("URL-prompt login = %v, %v", sess, err) |
| + } |
| +} |
| + |
| func TestResolvedAccountExplicitFlagsBypass(t *testing.T) { |
| useTempCredentials(t) |
| captureStdout(t) |
diff --git a/auth.go b/auth.go| index de3a1e22e..ec2c7bd16 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -285,11 +285,13 @@ func (f *credentialFile) pick(console string) (*credential, error) { |
| } |
| c := f.find(want) |
| if c == nil { |
| - return nil, fmt.Errorf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want) |
| + return nil, ¬LoggedInError{Console: want, |
| + msg: fmt.Sprintf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)} |
| } |
| if c.Expired(time.Now()) { |
| - return nil, fmt.Errorf("the credential for %s%s expired %s — run: carlos auth login -console %s", |
| - want, from, c.ExpiresAt.Format(time.RFC3339), want) |
| + return nil, ¬LoggedInError{Console: want, Expired: true, |
| + msg: fmt.Sprintf("the credential for %s%s expired %s — run: carlos auth login -console %s", |
| + want, from, c.ExpiresAt.Format(time.RFC3339), want)} |
| } |
| return c, nil |
| } |
| @@ -316,6 +318,18 @@ func (f *credentialFile) pick(console string) (*credential, error) { |
| } |
| } |
| +// notLoggedInError is pick's refusal when a NAMED console has no live |
| +// credential — typed so resolveMode's interactive path (#162) can see |
| +// which console to offer a login for, while Error() stays byte-identical |
| +// to the strings scripts have matched since the credential file existed. |
| +type notLoggedInError struct { |
| + Console string |
| + Expired bool |
| + msg string |
| +} |
| + |
| +func (e *notLoggedInError) Error() string { return e.msg } |
| + |
| // credentialPermWarning reports a credentials file other users can read. |
| // A warning rather than a refusal: locking someone out of their own tooling |
| // over a mode bit helps nobody, but saying nothing lets a world-readable |