Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions cmd/relayfile-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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 {
Expand Down
115 changes: 115 additions & 0 deletions cmd/relayfile-cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
Expand Down
Loading