diff --git a/.trajectories/completed/2026-05/traj_4vdcwo2iy630.json b/.trajectories/completed/2026-05/traj_4vdcwo2iy630.json new file mode 100644 index 00000000..d18c3b75 --- /dev/null +++ b/.trajectories/completed/2026-05/traj_4vdcwo2iy630.json @@ -0,0 +1,53 @@ +{ + "id": "traj_4vdcwo2iy630", + "version": 1, + "task": { + "title": "relayfile login: fall back to cloud browser flow when no --token" + }, + "status": "completed", + "startedAt": "2026-05-09T18:25:18.473Z", + "completedAt": "2026-05-09T18:27:13.341Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-05-09T18:25:23.176Z" + } + ], + "chapters": [ + { + "id": "chap_xmj2dpqakhb5", + "title": "Work", + "agentName": "default", + "startedAt": "2026-05-09T18:25:23.176Z", + "endedAt": "2026-05-09T18:27:13.341Z", + "events": [ + { + "ts": 1778351123176, + "type": "decision", + "content": "When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key: When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key", + "raw": { + "question": "When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key", + "chosen": "When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key", + "alternatives": [], + "reasoning": "Matches what 'relayfile setup' already does. Old API-key prompt path stays available behind --api-key flag for self-hosted users who don't have cloud." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "relayfile login now defaults to the cloud browser flow when --token is absent; legacy API-key prompt is preserved behind --api-key. Adds --cloud-api-url/--cloud-token/--no-open/--login-timeout flags mirroring 'setup', plus 3 unit tests.", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "9de722a6c6c747fca3e90fb62a9899253e742264", + "endRef": "9de722a6c6c747fca3e90fb62a9899253e742264" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-05/traj_4vdcwo2iy630.md b/.trajectories/completed/2026-05/traj_4vdcwo2iy630.md new file mode 100644 index 00000000..3e7ab96e --- /dev/null +++ b/.trajectories/completed/2026-05/traj_4vdcwo2iy630.md @@ -0,0 +1,31 @@ +# Trajectory: relayfile login: fall back to cloud browser flow when no --token + +> **Status:** ✅ Completed +> **Confidence:** 85% +> **Started:** May 9, 2026 at 08:25 PM +> **Completed:** May 9, 2026 at 08:27 PM + +--- + +## Summary + +relayfile login now defaults to the cloud browser flow when --token is absent; legacy API-key prompt is preserved behind --api-key. Adds --cloud-api-url/--cloud-token/--no-open/--login-timeout flags mirroring 'setup', plus 3 unit tests. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key +- **Chose:** When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key +- **Reasoning:** Matches what 'relayfile setup' already does. Old API-key prompt path stays available behind --api-key flag for self-hosted users who don't have cloud. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key: When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key diff --git a/.trajectories/index.json b/.trajectories/index.json index 4e67aa2f..6001eeac 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-05-09T18:22:10.451Z", + "lastUpdated": "2026-05-09T18:27:13.442Z", "trajectories": { "traj_mfyus7zfgxt2": { "title": "062-sdk-setup-client-workflow", @@ -221,6 +221,13 @@ "completedAt": "2026-05-09T13:57:04.293Z", "path": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-05/traj_6lyjg41p6a28.json", "compactedInto": "compact_xl96yexa79wg" + }, + "traj_4vdcwo2iy630": { + "title": "relayfile login: fall back to cloud browser flow when no --token", + "status": "completed", + "startedAt": "2026-05-09T18:25:18.473Z", + "completedAt": "2026-05-09T18:27:13.341Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-05/traj_4vdcwo2iy630.json" } } } \ No newline at end of file diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 8b82db21..83fb921f 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -378,7 +378,7 @@ func printUsage(w io.Writer) { Usage: relayfile relayfile setup [--provider PROVIDER] [--workspace NAME] [--local-dir DIR] - relayfile login --server URL [--token TOKEN] + relayfile login [--no-open] [--api-key] [--server URL] [--token TOKEN] relayfile workspace create NAME relayfile workspace use NAME relayfile workspace list @@ -405,7 +405,7 @@ Usage: Subcommands: setup Sign in, connect an integration, and mount the workspace - login Store credentials in ~/.relayfile/credentials.json + login Sign in via the Relayfile Cloud browser flow (or --api-key for self-hosted) workspace Create, select, list, or delete locally tracked workspaces integration Connect, list, or disconnect workspace integrations ops List or replay dead-lettered writeback ops @@ -1140,28 +1140,64 @@ func waitForInitialSync(serverURL, token, workspaceID, provider, localDir string func runLogin(args []string, stdin io.Reader, stdout io.Writer) error { fs := flag.NewFlagSet("login", flag.ContinueOnError) fs.SetOutput(io.Discard) - server := fs.String("server", envOrDefault("RELAYFILE_SERVER", envOrDefault("RELAYFILE_BASE_URL", defaultServerURL)), "relayfile server URL") - token := fs.String("token", strings.TrimSpace(os.Getenv("RELAYFILE_TOKEN")), "API token") - if err := fs.Parse(args); err != nil { + server := fs.String("server", envOrDefault("RELAYFILE_SERVER", envOrDefault("RELAYFILE_BASE_URL", defaultServerURL)), "relayfile server URL (only used with --api-key)") + token := fs.String("token", strings.TrimSpace(os.Getenv("RELAYFILE_TOKEN")), "relayfile API token") + cloudAPIURL := fs.String("cloud-api-url", envOrDefault("RELAYFILE_CLOUD_API_URL", defaultCloudAPIURL), "Relayfile Cloud API URL") + cloudToken := fs.String("cloud-token", strings.TrimSpace(os.Getenv("RELAYFILE_CLOUD_TOKEN")), "Relayfile Cloud access token; skips browser login when set") + apiKey := fs.Bool("api-key", false, "use the legacy API-key flow against --server instead of the cloud browser login") + noOpen := fs.Bool("no-open", false, "print the cloud sign-in URL instead of opening it") + loginTimeout := fs.Duration("login-timeout", 5*time.Minute, "cloud login timeout") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ + "server": true, + "token": true, + "cloud-api-url": true, + "cloud-token": true, + "api-key": false, + "no-open": false, + "login-timeout": true, + })); err != nil { return err } - serverValue := strings.TrimSpace(*server) - if serverValue == "" { - serverValue = defaultServerURL - } tokenValue := strings.TrimSpace(*token) - if tokenValue == "" { + + // Token explicitly provided → legacy server-credential flow. + if tokenValue != "" { + return loginWithAPIKey(strings.TrimSpace(*server), tokenValue, stdout) + } + + // Legacy interactive prompt, opt-in. + if *apiKey { prompted, err := promptLine(stdin, stdout, "API key: ") if err != nil { return err } - tokenValue = strings.TrimSpace(prompted) + prompted = strings.TrimSpace(prompted) + if prompted == "" { + return errors.New("token is required") + } + return loginWithAPIKey(strings.TrimSpace(*server), prompted, stdout) } - if tokenValue == "" { - return errors.New("token is required") + + // Default: cloud browser flow. + cloudAPI := strings.TrimRight(strings.TrimSpace(*cloudAPIURL), "/") + if cloudAPI == "" { + cloudAPI = defaultCloudAPIURL + } + creds, err := ensureCloudCredentials(cloudAPI, strings.TrimSpace(*cloudToken), *loginTimeout, !*noOpen, stdout) + if err != nil { + return err } + fmt.Fprintf(stdout, "Signed in to Relayfile Cloud at %s\n", creds.APIURL) + fmt.Fprintln(stdout, "Run 'relayfile setup' to create or join a workspace, or 'relayfile mount WORKSPACE' if you already have one.") + return nil +} +func loginWithAPIKey(serverValue, tokenValue string, stdout io.Writer) error { + serverValue = strings.TrimSpace(serverValue) + if serverValue == "" { + serverValue = defaultServerURL + } req, err := http.NewRequest(http.MethodGet, strings.TrimRight(serverValue, "/")+"/health", nil) if err != nil { return err diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index e7c28eb0..7fecdc70 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -1499,3 +1499,107 @@ func TestOpsReplayPostsToCloudAndRemovesLocalRecord(t *testing.T) { t.Fatalf("expected dead-letter record removed, got err=%v", err) } } + +// TestLoginWithExplicitTokenPersistsServerCreds covers the legacy API-key +// path: when --token is supplied, runLogin validates it against /health and +// writes ~/.relayfile/credentials.json (no cloud creds touched). +func TestLoginWithExplicitTokenPersistsServerCreds(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + var healthCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer rf_test" { + t.Fatalf("unexpected Authorization: %q", got) + } + healthCalls++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + var stdout bytes.Buffer + if err := run([]string{"login", "--server", server.URL, "--token", "rf_test"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("run login failed: %v\noutput:\n%s", err, stdout.String()) + } + if healthCalls != 1 { + t.Fatalf("expected 1 /health call, got %d", healthCalls) + } + creds, err := loadCredentials() + if err != nil { + t.Fatalf("loadCredentials failed: %v", err) + } + if creds.Token != "rf_test" { + t.Fatalf("expected stored token rf_test, got %q", creds.Token) + } + if _, err := os.Stat(cloudCredentialsPath()); !os.IsNotExist(err) { + t.Fatalf("expected no cloud credentials file written, got err=%v", err) + } +} + +// TestLoginDefaultsToCloudBrowserFlow covers the new default behavior: when +// no --token is provided, runLogin runs the cloud browser flow. We use +// --cloud-token to skip the actual browser handshake — ensureCloudCredentials +// short-circuits to writing cloud credentials when an explicit token is set. +func TestLoginDefaultsToCloudBrowserFlow(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + var stdout bytes.Buffer + if err := run([]string{ + "login", + "--cloud-api-url", "https://cloud.relayfile.test", + "--cloud-token", "cld_browser_token", + }, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("run login failed: %v\noutput:\n%s", err, stdout.String()) + } + + got := stdout.String() + if !strings.Contains(got, "Signed in to Relayfile Cloud") { + t.Fatalf("expected cloud sign-in confirmation, got %q", got) + } + creds, err := loadCloudCredentials() + if err != nil { + t.Fatalf("loadCloudCredentials failed: %v", err) + } + if creds.AccessToken != "cld_browser_token" { + t.Fatalf("expected stored cloud access token, got %q", creds.AccessToken) + } + if creds.APIURL != "https://cloud.relayfile.test" { + t.Fatalf("expected APIURL stored, got %q", creds.APIURL) + } + if _, err := os.Stat(credentialsPath()); !os.IsNotExist(err) { + t.Fatalf("expected no server credentials written, got err=%v", err) + } +} + +// TestLoginAPIKeyFlagPromptsForToken covers the opt-in legacy interactive +// flow: --api-key forces runLogin to prompt for an API key on stdin instead +// of running the cloud browser flow. +func TestLoginAPIKeyFlagPromptsForToken(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer prompted_key" { + t.Fatalf("unexpected Authorization: %q", got) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + stdin := strings.NewReader("prompted_key\n") + var stdout bytes.Buffer + if err := run([]string{"login", "--api-key", "--server", server.URL}, stdin, &stdout, &stdout); err != nil { + t.Fatalf("run login --api-key failed: %v\noutput:\n%s", err, stdout.String()) + } + creds, err := loadCredentials() + if err != nil { + t.Fatalf("loadCredentials failed: %v", err) + } + if creds.Token != "prompted_key" { + t.Fatalf("expected stored token prompted_key, got %q", creds.Token) + } +}