From 04c70cdc6c566aa5382872ef68ca3a6b2a461d7f Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 13:15:01 +0000 Subject: [PATCH 1/2] Add cmdio.IsInputTTY IsPromptSupported tells whether the CLI can render a prompt, so it also depends on stderr, NO_COLOR, TERM=dumb and Git Bash. A command that reads a plain line from stdin only needs to know whether a user can type into it. Co-authored-by: Isaac --- libs/cmdio/io.go | 9 +++++++++ libs/cmdio/io_test.go | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index 2b9b395ce13..7f0dd853e8c 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -83,6 +83,15 @@ func IsPromptSupported(ctx context.Context) bool { return c.capabilities.SupportsPrompt() } +// IsInputTTY reports whether stdin is a terminal, so that a user can type +// input into the command. Unlike IsPromptSupported, it does not depend on +// stderr, NO_COLOR, TERM=dumb or Git Bash, which only matter for rendering +// prompts. +func IsInputTTY(ctx context.Context) bool { + c := fromContext(ctx) + return c.capabilities.stdinIsTTY +} + // IsPagerSupported reports whether stdin, stdout, and stderr are all interactive // terminals. This is the requirement for a full-screen or navigable output // program: unlike IsPromptSupported it also checks stdout, so it returns false diff --git a/libs/cmdio/io_test.go b/libs/cmdio/io_test.go index 23872d5f95c..7c5aad9a6a6 100644 --- a/libs/cmdio/io_test.go +++ b/libs/cmdio/io_test.go @@ -3,6 +3,8 @@ package cmdio import ( "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { @@ -34,3 +36,14 @@ func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { c.releaseTeaProgram() } + +func TestIsInputTTY(t *testing.T) { + // Prompts are not supported without stderr as a terminal and with color, + // but a user can still type into stdin. + ctx := InContext(t.Context(), &cmdIO{capabilities: Capabilities{stdinIsTTY: true}}) + assert.True(t, IsInputTTY(ctx)) + assert.False(t, IsPromptSupported(ctx)) + + ctx = InContext(t.Context(), &cmdIO{capabilities: Capabilities{stderrIsTTY: true, color: true}}) + assert.False(t, IsInputTTY(ctx)) +} From dec6c3b8b0d9cc816b56ccf703cd8e6ff6353061 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 12:38:47 +0000 Subject: [PATCH 2/2] Store pending device code logins in the token store A non-interactive device code login is completed by a later CLI invocation. Entry gets a PendingLogin field that holds the device code and the user code until then, so that the device code stays in the user's token storage instead of being printed or passed on the command line. A pending entry has no token. Co-authored-by: Isaac --- libs/auth/storage/filestore.go | 5 +++-- libs/auth/storage/filestore_test.go | 22 ++++++++++++++++++++++ libs/auth/storage/keyring.go | 3 ++- libs/auth/storage/keyring_test.go | 13 +++++++++++++ libs/auth/storage/memory.go | 4 ++++ libs/auth/storage/memory_test.go | 18 ++++++++++++++++++ libs/auth/storage/storage.go | 19 ++++++++++++++++++- 7 files changed, 80 insertions(+), 4 deletions(-) diff --git a/libs/auth/storage/filestore.go b/libs/auth/storage/filestore.go index d7f13d397d3..18586a68ca4 100644 --- a/libs/auth/storage/filestore.go +++ b/libs/auth/storage/filestore.go @@ -51,6 +51,7 @@ const ( // the historical bare-token format, leaving room for additive sibling fields. type fileEntry struct { *oauth2.Token + PendingLogin *PendingLogin `json:"pending_login,omitempty"` } // tokenStoreFile is the format of the token store file. @@ -107,7 +108,7 @@ func (c *fileStore) Put(key string, e Entry) error { if f.Tokens == nil { f.Tokens = map[string]*fileEntry{} } - f.Tokens[key] = &fileEntry{Token: e.Token} + f.Tokens[key] = &fileEntry{Token: e.Token, PendingLogin: e.PendingLogin} return c.write(f) } @@ -123,7 +124,7 @@ func (c *fileStore) Lookup(key string) (Entry, error) { if !ok { return Entry{}, ErrNotFound } - return Entry{Token: fe.Token}, nil + return Entry{Token: fe.Token, PendingLogin: fe.PendingLogin}, nil } // Delete implements the Store interface. Removing a missing key is a no-op. diff --git a/libs/auth/storage/filestore_test.go b/libs/auth/storage/filestore_test.go index d905e304839..68e847cb4a1 100644 --- a/libs/auth/storage/filestore_test.go +++ b/libs/auth/storage/filestore_test.go @@ -36,6 +36,28 @@ func TestStoreAndLookup(t *testing.T) { assert.Equal(t, ErrNotFound, err) } +func TestStoreAndLookupPendingLogin(t *testing.T) { + f := setup(t) + c, err := NewFileStore(t.Context(), WithFileLocation(f)) + require.NoError(t, err) + pending := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + require.NoError(t, c.Put("x#device-code", Entry{PendingLogin: pending})) + + got, err := c.Lookup("x#device-code") + require.NoError(t, err) + assert.Nil(t, got.Token) + assert.Equal(t, pending, got.PendingLogin) + + raw, err := os.ReadFile(f) + require.NoError(t, err) + assert.JSONEq(t, `{ + "version": 1, + "tokens": { + "x#device-code": {"pending_login": {"device_code": "device-code", "user_code": "WDJBMJHT"}} + } + }`, string(raw)) +} + func TestNoStoreFileReturnsErrNotConfigured(t *testing.T) { l, err := NewFileStore(t.Context(), WithFileLocation(setup(t))) require.NoError(t, err) diff --git a/libs/auth/storage/keyring.go b/libs/auth/storage/keyring.go index e499c00490c..1beccdca3e5 100644 --- a/libs/auth/storage/keyring.go +++ b/libs/auth/storage/keyring.go @@ -47,7 +47,8 @@ type keyringBackend interface { // checksum, store time, ...) without breaking older CLI versions that read // the same entry. type keyringEntry struct { - Token *oauth2.Token `json:"token"` + Token *oauth2.Token `json:"token"` + PendingLogin *PendingLogin `json:"pending_login,omitempty"` } // zalandoBackend delegates to the process-wide zalando/go-keyring provider. diff --git a/libs/auth/storage/keyring_test.go b/libs/auth/storage/keyring_test.go index 7418bf7c7e8..77a7ced4779 100644 --- a/libs/auth/storage/keyring_test.go +++ b/libs/auth/storage/keyring_test.go @@ -118,6 +118,19 @@ func TestKeyringStore_Lookup_ReturnsStoredToken(t *testing.T) { assert.Equal(t, "Bearer", got.Token.TokenType) } +func TestKeyringStore_Lookup_ReturnsStoredPendingLogin(t *testing.T) { + backend := newFakeBackend() + c := newTestStore(backend) + + want := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + require.NoError(t, c.Put("my-profile#device-code", Entry{PendingLogin: want})) + + got, err := c.Lookup("my-profile#device-code") + require.NoError(t, err) + assert.Nil(t, got.Token) + assert.Equal(t, want, got.PendingLogin) +} + func TestKeyringStore_Lookup_MissingReturnsCacheErrNotFound(t *testing.T) { backend := newFakeBackend() c := newTestStore(backend) diff --git a/libs/auth/storage/memory.go b/libs/auth/storage/memory.go index 60918968c31..d639cf5f108 100644 --- a/libs/auth/storage/memory.go +++ b/libs/auth/storage/memory.go @@ -29,6 +29,10 @@ func cloneToken(t *oauth2.Token) *oauth2.Token { func cloneEntry(e Entry) Entry { e.Token = cloneToken(e.Token) + if e.PendingLogin != nil { + pending := *e.PendingLogin + e.PendingLogin = &pending + } return e } diff --git a/libs/auth/storage/memory_test.go b/libs/auth/storage/memory_test.go index abccf029248..7c8f4b6109b 100644 --- a/libs/auth/storage/memory_test.go +++ b/libs/auth/storage/memory_test.go @@ -55,6 +55,24 @@ func TestMemoryStore_PutAndLookupUseCopies(t *testing.T) { } } +func TestMemoryStore_PendingLoginUsesCopies(t *testing.T) { + s := NewMemoryStore() + pending := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + if err := s.Put("key1", Entry{PendingLogin: pending}); err != nil { + t.Fatalf("Put: %v", err) + } + + pending.DeviceCode = "mutated-after-store" + + got, err := s.Lookup("key1") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if got.PendingLogin.DeviceCode != "device-code" { + t.Fatalf("DeviceCode after store mutation: want %q, got %q", "device-code", got.PendingLogin.DeviceCode) + } +} + func TestMemoryStore_Delete(t *testing.T) { s := NewMemoryStore() if err := s.Put("key1", Entry{Token: &oauth2.Token{AccessToken: "abc"}}); err != nil { diff --git a/libs/auth/storage/storage.go b/libs/auth/storage/storage.go index 3aad20fc645..90253c55dcb 100644 --- a/libs/auth/storage/storage.go +++ b/libs/auth/storage/storage.go @@ -24,8 +24,25 @@ var ErrNotFound = errors.New("token not found") // without changing the Store interface. Backends persist it verbatim and never // interpret its contents. type Entry struct { - // Token is the cached OAuth token. Always set for stored entries. + // Token is the cached OAuth token. Set for every stored entry except a + // pending login. Token *oauth2.Token + + // PendingLogin is set instead of Token for a login that one CLI + // invocation started and a later one completes. + PendingLogin *PendingLogin +} + +// PendingLogin is a device code login that is waiting for the user's +// confirmation code. Keeping the device code in the store means it never +// leaves the machine that started the login. +type PendingLogin struct { + // DeviceCode is exchanged together with the confirmation code for a token. + DeviceCode string `json:"device_code"` + + // UserCode is the code the user enters in the browser. It identifies the + // login when a newer one replaced it under the same key. + UserCode string `json:"user_code"` } // Store is the CLI's token-storage abstraction: a key/value store with no