Skip to content

feat(auth): browser-assisted CLI login + profiles + auth/team commands#84

Open
LanusseMorais wants to merge 4 commits into
mainfrom
feature/PD-6071-cli-browser-login
Open

feat(auth): browser-assisted CLI login + profiles + auth/team commands#84
LanusseMorais wants to merge 4 commits into
mainfrom
feature/PD-6071-cli-browser-login

Conversation

@LanusseMorais

@LanusseMorais LanusseMorais commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a browser-assisted login flow to lsh, so users no longer need to manually copy an API token from the dashboard. Adds multi-team support via profiles, plus commands to inspect and switch the active context.

$ lsh login
Opening your browser to authorize this CLI...

  URL:
    https://www.latitude.sh/dashboard/cli/authorize?session=<id>

  Confirm this code matches what your browser shows:
    WDJB-MJHT

Waiting for approval... press Ctrl+C to cancel.

✅ Logged in as you@example.com on team Acme (profile: acme)

New commands

Command Description
lsh login Browser-assisted login (default). Falls back to manual URL on headless.
lsh login --with-token <T> Validates the token against /user/profile and stores it under a profile.
lsh auth status Show the active profile (email, team, key name, source).
lsh auth logout [--profile X | --all] Removes a profile. Revokes the API key remotely when source is browser.
lsh profile use <name> Set the active profile (accepts the profile name or the slug/id of the team it is bound to).
lsh profile list Lists the profiles you are logged into.

New global flag and env vars

Name Purpose
--profile <name> One-shot override of the default profile for a single invocation.
LATITUDESH_TOKEN Use this token directly, bypassing any stored profile. Useful in CI.
LSH_PROFILE Override the default profile via environment.
LSH_PROJECT Pre-fill the --project flag (skips the interactive prompt).

Interactive project picker

Commands that take --project (e.g. lsh servers list, lsh servers create) now prompt interactively when the flag is missing and stdin is a TTY:

? Select a project
  ▸ acme-prod        Acme Prod — proj_xxx
    acme-staging     Acme Staging — proj_yyy
    All projects     Run across every project in this team

For list commands, picking All projects (or passing --all-projects) lists across every project.

In non-interactive contexts the command fails with an actionable message:

Error: --project is required (pass --project=<id>, --all-projects, or set LSH_PROJECT)

Config file

The config at ~/.config/lsh/config.json now supports multiple profiles. Existing single-token configs are migrated automatically on first run.

Before:

{ "Authorization": "ak_xxx", "API-Version": "2023-06-01" }

After (auto-migrated as profile default):

{
  "default_profile": "acme",
  "profiles": {
    "acme": {
      "authorization": "ak_xxx",
      "team_id": "...", "team_name": "Acme", "team_slug": "acme",
      "email": "you@example.com",
      "source": "with-token",
      "api_version": "2023-06-01"
    }
  }
}

Permissions: 0700 directory, 0600 file.

Backward compatibility

  • lsh login <token> (positional) still works but prints a deprecation warning.
  • Old config layout is migrated transparently.
  • Generated command flags (--project=<id> etc.) unchanged.

Breaking change

lsh servers list and lsh virtual-networks list no longer silently list across all projects when --project is missing. Behaviour now:

  • TTY: shows the picker.
  • Non-TTY: fails with the message above.

Existing scripts can opt back into the old behaviour with --all-projects, --project=<id>, or LSH_PROJECT=<id>.

Notes

  • Two generated files (cli/get_servers_operation.go, cli/get_virtual_networks_operation.go) gained a manually-added --all-projects flag. Lines are tagged // MANUAL — keep when regenerating to survive future swagger regenerations.

How to test

go build -o ./lsh-dev .

1. Token login (covers most of the flow without needing the browser page)

./lsh-dev login --with-token <YOUR_TOKEN>
./lsh-dev auth status
./lsh-dev profile list
cat ~/.config/lsh/config.json | jq

Expected:

  • auth status shows Email, Team, Source: with-token.
  • profile list shows the profile with a * marker.
  • Config has a profiles map with the team slug as key.

2. Switch between profiles

Run login --with-token twice with tokens from different teams (or pass --profile=<name> to override the auto-naming).

./lsh-dev profile list
./lsh-dev profile use <other-team-slug>
./lsh-dev auth status                              # default switched
./lsh-dev --profile <slug> auth status             # one-shot override
LSH_PROFILE=<slug> ./lsh-dev auth status           # env override
LATITUDESH_TOKEN=ak_xxx ./lsh-dev auth status      # bypass any profile

3. Logout

./lsh-dev auth logout                              # active profile
./lsh-dev auth logout --profile <slug>             # specific
./lsh-dev auth logout --all                        # everything

Logout on a profile with source: browser revokes the API key remotely (you can verify under Settings → API Keys); logout on a --with-token profile only clears it locally.

4. Project picker

After logging in, run a command that needs a project:

./lsh-dev servers list

Expected: an interactive picker listing projects + an All projects entry. Selecting a project filters results; selecting All projects lists everything.

Other invocations to test:

./lsh-dev servers list --project=<id>              # filter, no prompt
./lsh-dev servers list --all-projects              # all, no prompt
LSH_PROJECT=<id> ./lsh-dev servers list            # env, no prompt
./lsh-dev servers list < /dev/null                 # non-TTY, fails with an actionable error

5. Browser-assisted login

Requires the matching dashboard page deployed (the page lives in a separate repo and is being shipped in parallel — you'll see the URL the CLI prints).

./lsh-dev login

Expected: prints the URL + a user_code; opens the browser if available; polls until you approve. After approval, prints ✅ Logged in as .... If the browser cannot be opened (SSH session, no DISPLAY, piped stdin), the CLI prints the URL and waits — you open it from another machine.

6. Config migration

Manually drop a legacy config and verify it gets migrated:

mkdir -p ~/.config/lsh
cat > ~/.config/lsh/config.json <<'EOF2'
{ "Authorization": "ak_old_token", "API-Version": "2023-06-01" }
EOF2

./lsh-dev auth status
cat ~/.config/lsh/config.json | jq

Expected: a default profile materialized from the legacy field, source: with-token. Subsequent runs are no-ops.

Greptile Summary

This PR adds browser-assisted login via a polling CLI-session flow, multi-team profile support, interactive project picker, and auth/profile subcommand groups. Existing single-token configs are auto-migrated on first load and the change is backward-compatible for scripted callers via --all-projects, LSH_PROJECT, and LATITUDESH_TOKEN.

  • Browser login (cli/auth_login_browser.go): creates a server-side CLI session, polls until approved or timed-out, and persists the resulting API key as a named profile.
  • Multi-profile config (internal/config/): new File / Profile types replace the flat token map; migrateLegacyInto silently upgrades old configs on first Load, now with a best-effort Save to persist the migration to disk.
  • Project picker (cli/project_flag.go, internal/prompt/project.go): PersistentPreRunE resolves --project for every command that needs one — env var → explicit flag → interactive prompt → error — and the "All projects" path leaves --project unset so the generated commands fall back to their original no-filter behavior.

Confidence Score: 5/5

The PR is safe to merge. The auth, config, and polling logic is sound, previously-flagged issues have been addressed, and the change ships good test coverage for both the config package and the authclient.

The migration, polling, and profile-persistence paths all have dedicated tests and handle edge cases (empty profiles, concurrent legacy+new config, approved-without-key). The remaining observations are cosmetic output issues that do not affect correctness.

No files require special attention. cli/auth_login_browser.go has a minor UX inconsistency in the headless message and an edge case in the poll loop for unexpected status values, but neither affects the correctness of the login flow.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as lsh
    participant API as Latitude API
    participant Browser

    User->>CLI: lsh login
    CLI->>API: POST /auth/cli_sessions
    API-->>CLI: id, secret, user_code, authorize_url
    CLI->>User: Print URL and user_code
    CLI->>Browser: Open authorize_url if not headless
    loop Poll every 2s up to 5m30s
        CLI->>API: GET /auth/cli_sessions/id with X-CLI-Secret
        alt status pending
            API-->>CLI: status pending
        else status approved
            API-->>CLI: status approved, api_key, team, user
            CLI->>CLI: Save profile to config.json
            CLI->>User: Logged in as email on team
        else 404 within 15s grace
            API-->>CLI: 404 not yet propagated
        else 410 or 401
            API-->>CLI: Terminal error
            CLI->>User: Error message
        end
    end

    note over User,CLI: Token login path
    User->>CLI: lsh login --with-token TOKEN
    CLI->>API: GET /user/profile with token
    API-->>CLI: email
    CLI->>API: GET /team with token
    API-->>CLI: team id, name, slug
    CLI->>CLI: Save profile to config.json
    CLI->>User: Logged in confirmation

    note over User,CLI: Logout path
    User->>CLI: lsh auth logout
    CLI->>CLI: Load config, resolve profile
    opt source is browser
        CLI->>API: DELETE /auth/api_keys/key_id
    end
    CLI->>CLI: RemoveProfile, Save config
    CLI->>User: Removed profile
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
cli/auth_login_browser.go:80-91
In headless mode the browser is never opened, but the first printed line still reads "Opening your browser to authorize this CLI..." — which is factually wrong for an SSH session or a server with no display. Users in those environments see the misleading message before the corrective note that appears a few lines later.

```suggestion
func printAuthorizePrompt(session *authclient.Session, headless bool) {
	if headless {
		fmt.Println("Authorize this CLI from your browser (headless environment detected):")
	} else {
		fmt.Println("Opening your browser to authorize this CLI...")
	}
	fmt.Println()
	fmt.Println("  URL:")
	fmt.Printf("    %s\n", session.AuthorizeURL)
	fmt.Println()
	fmt.Println("  Confirm this code matches what your browser shows:")
	fmt.Printf("    %s\n", session.UserCode)
	if headless {
		fmt.Println()
		fmt.Println("  (open the URL above on a machine with a browser)")
	}
```

### Issue 2 of 3
cli/auth_login_browser.go:121-122
**Non-"approved" status values treated as "pending" indefinitely**

The only status that exits the loop early is `"approved"`. Any other non-error 200 response — e.g., a server-side `"denied"` or `"cancelled"` status returned in the body — falls through to the `// status=pending → keep polling` path and blocks the user for the full 5.5-minute `loginTimeout`. If the server can communicate rejection via a response body status (rather than exclusively via HTTP 4xx codes), the user would see no feedback until the deadline.

### Issue 3 of 3
cli/profile.go:116-124
When a profile name is exactly 20 characters, appending `marker` (`" *"`) makes the combined string 22 characters, which overflows the `%-20s` field and shifts all subsequent columns to the right. The marker should be printed in its own column to preserve alignment regardless of name length.

```suggestion
	fmt.Printf("%-20s %-2s %-30s %s\n", "PROFILE", "", "TEAM", "EMAIL")
	for _, name := range names {
		p := f.Profiles[name]
		marker := ""
		if name == f.DefaultProfile {
			marker = "*"
		}
		fmt.Printf("%-20s %-2s %-30s %s\n", name, marker, formatTeam(p), emptyAsDash(p.Email))
	}
```

Reviews (3): Last reviewed commit: "fix(auth): send API-Version header on au..." | Re-trigger Greptile

@LanusseMorais

Copy link
Copy Markdown
Collaborator Author

@greptile review please

Comment thread cli/auth_login_browser.go
Comment thread internal/config/config.go
Comment thread cli/auth_login_token.go
Comment thread internal/config/config.go Outdated
…sist legacy migration, don't overwrite default profile on login, reset poll backoff + fail-fast on approved-without-key, honor LATITUDESH_TOKEN in auth status, env api-version for --with-token, stderr warnings, filepath.Dir)
@LanusseMorais

Copy link
Copy Markdown
Collaborator Author

@greptile review

@LanusseMorais

Copy link
Copy Markdown
Collaborator Author

@greptile review please

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants