carlos / platform Public

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>
Paul Campbell 577740a19a22de32c66916540012c6335585d9c6 parent afe7534
4 files changed, +225 −9
  • account_choose.go +18 −0
  • apimode.go +66 −6
  • apimode_test.go +124 −0
  • auth.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 = &notLoggedInError{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, &notLoggedInError{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, &notLoggedInError{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