Merge pull request #1774 from entireio/fix/1773-warn-logged-out-import · Entire
Merge pull request #1774 from entireio/fix/1773-warn-logged-out-import
6e06e26→main
karthik-rameshkumar·21h ago·4 files·+171 added/-0 removed
feat(import): warn when importing agent history while logged out
Changes
cmd/entire/cli
Mimport_cmd.go+4
Aimport_sync_notice.go+68
Aimport_sync_notice_test.go+92
Msetup_import.go+7
78 unmodified lines
79
80
81
82
83
84
85
86
87
88
78 unmodified lines
}
fmt.Fprintf(c.OutOrStdout(), "%s %d turn(s) from %d session(s) (%d already imported).
",
verb, res.TurnsImported, res.SessionsScanned, res.TurnsSkipped)
// A dry run writes nothing locally, so there is nothing to sync.
if !dryRun {
warnIfImportNotSynced(c.OutOrStdout(), res.TurnsImported > 0 || res.TurnsSkipped > 0)
}
return nil
},
}
Mcmd/entire/cli/import_cmd.go+4
package cli
import (
"fmt"
"io"
"os"
"github.com/entireio/cli/cmd/entire/cli/auth"
)
// Local auth reads, as package vars so the login heuristic's branching is
// testable without a real keyring or config dir. Production wiring is the real
// auth functions.
var (
importListContexts = auth.Contexts
importTokenForContext = auth.LoginTokenForContext
)
// importLoggedIn reports whether there is an active login the imported history
// could sync under: an ENTIRE_TOKEN env token, or a current stored login context
// that still has a token in the token store. It is local-only (env, contexts.json,
// and a token-store read) and never makes a network call, so it is safe on the
// import path.
//
// This is a presence check, not a liveness check. LoginTokenForContext returns a
// present-but-expired token without error, so a dead-but-not-removed login can
// still read as "logged in": confirming a token is actually usable needs a
// network refresh, which we deliberately avoid here (same reason the pre-push
// hook avoids ls-remote — no surprise auth prompts mid-command). That narrow
// residual false-negative (expired token → notice suppressed) is accepted to
// keep the check local and prompt-free; the common broken case this guards
// against — no context, or a context whose token was removed — is handled.
//
// Package var so tests can force the whole outcome (see #1773 review thread).
var importLoggedIn = func() bool {
if os.Getenv(auth.EnvTokenVar) != "" {
return true
}
ctxs, current, err := importListContexts()
if err != nil || current == "" {
return false
}
for _, c := range ctxs {
if c.Name == current {
tok, terr := importTokenForContext(c)
return terr == nil && tok != ""
}
}
return false
}
// warnIfImportNotSynced prints a one-time notice, when the user is not logged
// in, that imported agent history is stored locally only and will not appear in
// the Entire dashboard. It is a no-op when logged in or when nothing local was
// imported.
//
// Import writes read-only checkpoints to the local entire/checkpoints/v1 store
// and never syncs on its own; sync happens later via the git pre-push hook once
// logged in. Importing while logged out therefore succeeds locally but silently
// never reaches the dashboard — this notice surfaces that instead of leaving the
// user to discover an empty dashboard (see issue #1773).
func warnIfImportNotSynced(w io.Writer, importedLocalHistory bool) {
if !importedLocalHistory || importLoggedIn() {
return
}
fmt.Fprintln(w, "Note: you're not logged in, so this history was imported locally only and won't appear in your Entire dashboard.")
fmt.Fprintln(w, "Log in with 'entire login' before importing to have your history synced.")
}
Acmd/entire/cli/import_sync_notice.go+68
package cli
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/entireio/cli/cmd/entire/cli/auth"
"github.com/entireio/cli/internal/entireclient/contexts"
)
func TestWarnIfImportNotSynced(t *testing.T) {
// Mutates the package-level importLoggedIn seam, so it cannot run in
// parallel with other tests that read it.
orig := importLoggedIn
t.Cleanup(func() { importLoggedIn = orig })
cases := []struct {
name string
loggedIn bool
imported bool
wantNotice bool
}{
{name: "logged out with imported history warns", loggedIn: false, imported: true, wantNotice: true},
{name: "logged in does not warn", loggedIn: true, imported: true, wantNotice: false},
{name: "nothing imported does not warn", loggedIn: false, imported: false, wantNotice: false},
{name: "logged in and nothing imported does not warn", loggedIn: true, imported: false, wantNotice: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
importLoggedIn = func() bool { return tc.loggedIn }
var buf bytes.Buffer
warnIfImportNotSynced(&buf, tc.imported)
got := buf.String()
hasNotice := strings.Contains(got, "not logged in") && strings.Contains(got, "entire login")
if hasNotice != tc.wantNotice {
t.Fatalf("warnIfImportNotSynced(logged_in=%v, imported=%v): notice=%v, want %v; output=%q",
tc.loggedIn, tc.imported, hasNotice, tc.wantNotice, got)
}
})
}
}
// TestImportLoggedIn exercises the default login heuristic's branching via the
// local-read seams. The key case (#1773 review): a current context that exists
// but has no stored token must NOT count as logged in, so the sync notice still
// fires. Mutates package-level seams, so no t.Parallel.
func TestImportLoggedIn(t *testing.T) {
origCtx, origTok := importListContexts, importTokenForContext
t.Cleanup(func() { importListContexts, importTokenForContext = origCtx, origTok })
// Ensure no env token leaks in from the environment for the context cases.
t.Setenv(auth.EnvTokenVar, "")
withCurrent := func() ([]*contexts.Context, string, error) {
return []*contexts.Context{{Name: "prod"}}, "prod", nil
}
t.Run("current context with a stored token is logged in", func(t *testing.T) {
importListContexts = withCurrent
importTokenForContext = func(*contexts.Context) (string, error) { return "stored-token", nil }
if !importLoggedIn() {
t.Fatal("context with a token should count as logged in")
}
})
t.Run("current context with a missing token is NOT logged in", func(t *testing.T) {
importListContexts = withCurrent
importTokenForContext = func(*contexts.Context) (string, error) {
return "", errors.New("no token stored")
}
if importLoggedIn() {
t.Fatal("context present but token missing must not count as logged in")
}
})
t.Run("no current context is not logged in", func(t *testing.T) {
importListContexts = func() ([]*contexts.Context, string, error) { return nil, "", nil }
importTokenForContext = func(*contexts.Context) (string, error) { return "stored-token", nil }
if importLoggedIn() {
t.Fatal("no current context should not count as logged in")
}
})
t.Run("env token counts as logged in even with no context", func(t *testing.T) {
t.Setenv(auth.EnvTokenVar, "env-token")
importListContexts = func() ([]*contexts.Context, string, error) { return nil, "", nil }
if !importLoggedIn() {
t.Fatal("ENTIRE_TOKEN should count as logged in")
}
})
}
Acmd/entire/cli/import_sync_notice_test.go+92
// import_cmd.go; without it only always-on secret scanning would run.
strategy.EnsureRedactionConfigured()
var importedLocalHistory bool
for _, e := range selected {
res, err := agentimport.Run(ctx, repo, e.imp, agentimport.Options{
RepoRoot: repoRoot,
fmt.Fprintf(w, "Note: could not import %s history: %v\n", e.displayName, err)
continue
}
if res.TurnsImported > 0 || res.TurnsSkipped > 0 {
importedLocalHistory = true
}
fmt.Fprintf(w, "Imported %d turn(s) from %d session(s) (%d already imported).\n",
res.TurnsImported, res.SessionsScanned, res.TurnsSkipped)
}
// Enable often runs before the user has logged in; surface once that a
// logged-out import stays local and won't reach the dashboard (issue #1773).
warnIfImportNotSynced(w, importedLocalHistory)
// pluralSessions renders a session count with correct pluralization.