diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index a4e917f7..f7210f35 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -514,6 +514,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return runSetup(args[1:], stdin, stdout) case "login": return runLogin(args[1:], stdin, stdout) + case "logout": + return runLogout(args[1:], stdout) case "workspace": return runWorkspace(args[1:], stdin, stdout) case "integration": @@ -588,6 +590,8 @@ func printHelpForArgs(args []string, stdout io.Writer) { fmt.Fprintln(stdout, "Usage: relayfile setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR]") case "login": fmt.Fprintln(stdout, "Usage: relayfile login [--no-open] [--api-key] [--server URL] [--token TOKEN]") + case "logout": + fmt.Fprintln(stdout, "Usage: relayfile logout") case "workspace": printWorkspaceUsage(stdout, subcommand) case "integration": @@ -746,6 +750,7 @@ Usage: relayfile relayfile setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR] relayfile login [--no-open] [--api-key] [--server URL] [--token TOKEN] + relayfile logout relayfile workspace create NAME relayfile workspace join WORKSPACE_ID [--name NAME] [--write] relayfile workspace use NAME @@ -790,6 +795,7 @@ Usage: Subcommands: setup Sign in, connect an integration, and mount the workspace login Sign in via agent-relay cloud login (or --api-key for self-hosted) + logout Clear Relayfile credentials from this machine workspace Create, join, select via agent-relay, list, show current, or delete locally tracked workspaces integration Connect, discover, list, disconnect, or adopt workspace integrations ops List or replay dead-lettered writeback ops @@ -2123,6 +2129,24 @@ func runLogin(args []string, stdin io.Reader, stdout io.Writer) error { return nil } +func runLogout(args []string, stdout io.Writer) error { + if len(args) > 0 { + return errors.New("usage: relayfile logout") + } + removed, err := clearAuthCredentials() + if err != nil { + return err + } + if removed == 0 { + fmt.Fprintln(stdout, "Relayfile is already logged out.") + fmt.Fprintln(stdout, "Agent Relay cloud session left intact; run `agent-relay cloud logout` to fully sign out.") + return nil + } + fmt.Fprintln(stdout, "Logged out of Relayfile on this machine.") + fmt.Fprintln(stdout, "Agent Relay cloud session left intact; run `agent-relay cloud logout` to fully sign out.") + return nil +} + func loginWithAPIKey(serverValue, tokenValue string, stdout io.Writer) error { serverValue = strings.TrimSpace(serverValue) if serverValue == "" { @@ -7253,6 +7277,54 @@ func removeCredentialFile(path string) error { return nil } +func removeCredentialFileIfExists(path string) (bool, error) { + if err := os.Remove(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil +} + +func removeCredentialDirIfExists(path string) (bool, error) { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + if err := os.RemoveAll(path); err != nil { + return false, err + } + return true, nil +} + +func clearAuthCredentials() (int, error) { + removed := 0 + for _, path := range []string{ + credentialsPath(), + cloudCredentialsPath(), + delegatedCredentialsPath(), + } { + ok, err := removeCredentialFileIfExists(path) + if err != nil { + return removed, fmt.Errorf("remove %s: %w", path, err) + } + if ok { + removed++ + } + } + ok, err := removeCredentialDirIfExists(filepath.Join(configDir(), "delegated")) + if err != nil { + return removed, fmt.Errorf("remove delegated credential cache: %w", err) + } + if ok { + removed++ + } + return removed, nil +} + // saveLegacyCloudCredentials is retained for stale-store cleanup tests only. // Relayfile no longer owns the canonical cloud session. func saveLegacyCloudCredentials(creds cloudCredentials) error { diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 2a5c8ac4..b4112b0e 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -170,6 +170,7 @@ func TestHelpFlagPrintsUsageForCommandsAndSubcommands(t *testing.T) { {name: "root long", args: []string{"--help"}, want: "relayfile is the RelayFile CLI."}, {name: "setup", args: []string{"setup", "-h"}, want: "Usage: relayfile setup"}, {name: "login", args: []string{"login", "-h"}, want: "Usage: relayfile login"}, + {name: "logout", args: []string{"logout", "-h"}, want: "Usage: relayfile logout"}, {name: "workspace group", args: []string{"workspace", "-h"}, want: "relayfile workspace create NAME"}, {name: "workspace create", args: []string{"workspace", "create", "-h"}, want: "Usage: relayfile workspace create NAME"}, {name: "workspace join", args: []string{"workspace", "join", "-h"}, want: "Usage: relayfile workspace join WORKSPACE_ID"}, @@ -4193,6 +4194,120 @@ exit 2 } } +func TestLogoutClearsAuthCredentialsOnly(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + if err := saveCredentials(credentials{Server: "https://relayfile.test", Token: "rf_legacy"}); err != nil { + t.Fatalf("saveCredentials failed: %v", err) + } + if err := saveLegacyCloudCredentials(cloudCredentials{ + APIURL: "https://cloud.relayfile.test", + AccessToken: "cld_legacy", + }); err != nil { + t.Fatalf("saveLegacyCloudCredentials failed: %v", err) + } + defaultDelegated := delegatedCredentialsPath() + if err := delegatedauth.SaveAtomic(defaultDelegated, delegatedauth.Bundle{ + Token: "rf_delegated", + RefreshToken: "refresh_delegated", + RelayfileWorkspaceID: "ws_demo", + RelayfileURL: "https://relayfile.test", + RelayauthURL: "https://relayauth.test", + }); err != nil { + t.Fatalf("save default delegated credentials failed: %v", err) + } + cachedDelegated := delegatedCredentialsPathForRequest("ws_demo", defaultJoinScopes) + if err := delegatedauth.SaveAtomic(cachedDelegated, delegatedauth.Bundle{ + Token: "rf_cached", + RefreshToken: "refresh_cached", + RelayfileWorkspaceID: "ws_demo", + RelayfileURL: "https://relayfile.test", + RelayauthURL: "https://relayauth.test", + }); err != nil { + t.Fatalf("save cached delegated credentials failed: %v", err) + } + agentRelayAuth := agentRelayCloudAuthPath() + if err := os.MkdirAll(filepath.Dir(agentRelayAuth), 0o700); err != nil { + t.Fatalf("mkdir agent-relay auth dir failed: %v", err) + } + if err := os.WriteFile(agentRelayAuth, []byte(`{"accessToken":"cld_agent"}`), 0o600); err != nil { + t.Fatalf("write agent-relay cloud auth failed: %v", err) + } + catalog := workspaceCatalog{ + Default: "demo", + Workspaces: []workspaceRecord{{ + Name: "demo", + ID: "ws_demo", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }}, + } + if err := saveWorkspaceCatalog(catalog); err != nil { + t.Fatalf("saveWorkspaceCatalog failed: %v", err) + } + + var stdout bytes.Buffer + if err := run([]string{"logout"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("run logout failed: %v\noutput:\n%s", err, stdout.String()) + } + if got := stdout.String(); !strings.Contains(got, "Logged out of Relayfile on this machine.") { + t.Fatalf("expected logout success message, got %q", got) + } + if got := stdout.String(); !strings.Contains(got, "Agent Relay cloud session left intact") { + t.Fatalf("expected shared-session note, got %q", got) + } + for _, path := range []string{ + credentialsPath(), + cloudCredentialsPath(), + defaultDelegated, + filepath.Join(configDir(), "delegated"), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s removed, got err=%v", path, err) + } + } + if _, err := os.Stat(agentRelayAuth); err != nil { + t.Fatalf("expected shared agent-relay cloud auth preserved, got err=%v", err) + } + loadedCatalog, err := loadWorkspaceCatalog() + if err != nil { + t.Fatalf("loadWorkspaceCatalog failed: %v", err) + } + if loadedCatalog.Default != "demo" || len(loadedCatalog.Workspaces) != 1 { + t.Fatalf("expected workspace catalog preserved, got %+v", loadedCatalog) + } +} + +func TestLogoutIsIdempotentWhenNoCredentialsExist(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + var stdout bytes.Buffer + if err := run([]string{"logout"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("run logout failed: %v\noutput:\n%s", err, stdout.String()) + } + if got := stdout.String(); !strings.Contains(got, "Relayfile is already logged out.") { + t.Fatalf("expected already-logged-out message, got %q", got) + } + if got := stdout.String(); !strings.Contains(got, "Agent Relay cloud session left intact") { + t.Fatalf("expected shared-session note, got %q", got) + } +} + +func TestLogoutRejectsExtraArguments(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + var stdout bytes.Buffer + err := run([]string{"logout", "demo"}, strings.NewReader(""), &stdout, &stdout) + if err == nil { + t.Fatal("expected logout with extra arg to fail") + } + if got := err.Error(); !strings.Contains(got, "usage: relayfile logout") { + t.Fatalf("expected logout usage error, got %q", got) + } +} + func TestPrepareWorkspaceCommandClientBootstrapsDelegatedCredentialsDespiteStaleLegacyToken(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t)