Skip to content

Commit 4dea362

Browse files
authored
[HDX-3976] CLI: migrate from apiUrl to appUrl with interactive login flow (#2101)
## Summary Migrates the CLI from using API URLs (`-s, --server`) to app URLs (`-a, --app-url`), and adds interactive login prompts on expired/missing sessions. Linear: https://linear.app/clickhouse/issue/HDX-3976 --- ## Breaking Change **The `-s` / `--server` flag has been removed.** All commands (except `upload-sourcemaps`) now use `-a` / `--app-url` instead. The URL semantics have changed: users should now provide the **HyperDX app URL** (e.g. `http://localhost:8080`), not the API URL. The CLI derives the API URL internally by appending `/api`. | Before | After | |---|---| | `hdx auth login -s http://localhost:8080/api` | `hdx auth login -a http://localhost:8080` | | `hdx tui -s http://localhost:8080/api` | `hdx tui -a http://localhost:8080` | | `hdx sources -s http://localhost:8080/api` | `hdx sources -a http://localhost:8080` | > **Note:** `upload-sourcemaps` is unchanged — it still uses `--apiUrl` / `-u` as before. **Existing saved sessions are auto-migrated** — old `session.json` files with `apiUrl` are converted to `appUrl` on first load. --- ## Changes ### `apiUrl` → `appUrl` migration - `ApiClient` now accepts `appUrl` and derives `apiUrl` by appending `/api` - `SessionConfig` stores `appUrl`; legacy sessions with `apiUrl` auto-migrate on load - All commands use `-a, --app-url` instead of `-s, --server` ### Interactive login flow (HDX-3976) - `hdx auth login` no longer requires `-a` — it prompts interactively for login method, app URL, then credentials - Login method selector is extensible (currently Email/Password, designed for future OAuth support) - **Expired sessions now prompt for re-login** instead of printing an error and exiting - The app URL field is autofilled with the last used value so users can just hit Enter - No longer requires manual deletion of `~/.config/hyperdx/cli/session.json` to recover from expired sessions - Non-TUI commands (`sources`, `dashboards`, `query`) also launch interactive login on expired/missing sessions via `ensureSession()` helper ### TUI (`App.tsx`) - Detects expired session on mount and shows "Session expired" message with editable URL field - If the user changes the URL during re-login, the client is recreated - 401/403 errors during data loading bounce back to the login screen instead of showing raw error messages ### Input validation & error handling - App URL inputs are validated — rejects non-`http://` or `https://` URLs with a clear inline error - `ApiClient.login()` catches network/URL errors and returns `false` instead of crashing - `ApiClient.login()` verifies the session after a 302/200 response by calling `checkSession()` — prevents false "Logged in" messages from servers that return 302 without a valid session (e.g. SSO redirects) - Login failure messages now mention both credentials and server URL --- ## Files changed - `packages/cli/src/api/client.ts` — accepts `appUrl`, derives `apiUrl`, exposes `getAppUrl()`, login validation - `packages/cli/src/utils/config.ts` — `SessionConfig.appUrl`, backward-compat migration - `packages/cli/src/cli.tsx` — `-a` flag, `LoginPrompt`, `ReLoginPrompt`, `ensureSession()`, URL validation - `packages/cli/src/App.tsx` — expired session detection, editable URL on re-login, 401/403 handling - `packages/cli/src/components/LoginForm.tsx` — app URL prompt field, `message` prop, URL validation
1 parent 7a9882d commit 4dea362

6 files changed

Lines changed: 483 additions & 117 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@hyperdx/cli": minor
3+
---
4+
5+
**Breaking:** Replace `-s`/`--server` flag with `-a`/`--app-url` across all CLI commands (except `upload-sourcemaps`). Users should now provide the HyperDX app URL instead of the API URL — the CLI derives the API URL by appending `/api`.
6+
7+
- `hdx auth login` now prompts interactively for login method, app URL, and credentials (no flags required)
8+
- Expired/missing sessions prompt for re-login with the last URL autofilled instead of printing an error
9+
- Add URL input validation and post-login session verification
10+
- Existing saved sessions are auto-migrated from `apiUrl` to `appUrl`

packages/cli/src/App.tsx

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import EventViewer from '@/components/EventViewer';
1818
type Screen = 'loading' | 'login' | 'pick-source' | 'events' | 'alerts';
1919

2020
interface AppProps {
21-
apiUrl: string;
21+
appUrl: string;
2222
/** Pre-set search query from CLI flags */
2323
query?: string;
2424
/** Pre-set source name from CLI flags */
@@ -27,9 +27,11 @@ interface AppProps {
2727
follow?: boolean;
2828
}
2929

30-
export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
30+
export default function App({ appUrl, query, sourceName, follow }: AppProps) {
3131
const [screen, setScreen] = useState<Screen>('loading');
32-
const [client] = useState(() => new ApiClient({ apiUrl }));
32+
const [client, setClient] = useState(() => new ApiClient({ appUrl }));
33+
const [currentAppUrl, setCurrentAppUrl] = useState(appUrl);
34+
const [sessionExpired, setSessionExpired] = useState(false);
3335
const [eventSources, setLogSources] = useState<SourceResponse[]>([]);
3436
const [savedSearches, setSavedSearches] = useState<SavedSearchResponse[]>([]);
3537
const [selectedSource, setSelectedSource] = useState<SourceResponse | null>(
@@ -43,18 +45,19 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
4345
(async () => {
4446
const valid = await client.checkSession();
4547
if (valid) {
46-
await loadData();
48+
await loadData(client);
4749
} else {
50+
setSessionExpired(true);
4851
setScreen('login');
4952
}
5053
})();
5154
}, []);
5255

53-
const loadData = async () => {
56+
const loadData = async (apiClient: ApiClient) => {
5457
try {
5558
const [sources, searches] = await Promise.all([
56-
client.getSources(),
57-
client.getSavedSearches().catch(() => [] as SavedSearchResponse[]),
59+
apiClient.getSources(),
60+
apiClient.getSavedSearches().catch(() => [] as SavedSearchResponse[]),
5861
]);
5962

6063
const queryableSources = sources.filter(
@@ -92,14 +95,34 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
9295

9396
setScreen('pick-source');
9497
} catch (err: unknown) {
95-
setError(err instanceof Error ? err.message : String(err));
98+
const msg = err instanceof Error ? err.message : String(err);
99+
// Treat auth errors as session issues — bounce back to login
100+
if (msg.includes('401') || msg.includes('403')) {
101+
setSessionExpired(true);
102+
setScreen('login');
103+
return;
104+
}
105+
setError(msg);
96106
}
97107
};
98108

99-
const handleLogin = async (email: string, password: string) => {
100-
const ok = await client.login(email, password);
109+
const handleLogin = async (
110+
loginAppUrl: string,
111+
email: string,
112+
password: string,
113+
) => {
114+
// Recreate client if the user changed the URL
115+
let activeClient = client;
116+
if (loginAppUrl !== currentAppUrl) {
117+
activeClient = new ApiClient({ appUrl: loginAppUrl });
118+
setClient(activeClient);
119+
setCurrentAppUrl(loginAppUrl);
120+
}
121+
122+
const ok = await activeClient.login(email, password);
101123
if (ok) {
102-
await loadData();
124+
setSessionExpired(false);
125+
await loadData(activeClient);
103126
}
104127
return ok;
105128
};
@@ -148,13 +171,23 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
148171
return (
149172
<Box paddingX={1}>
150173
<Text>
151-
<Spinner type="dots" /> Connecting to {apiUrl}
174+
<Spinner type="dots" /> Connecting to {currentAppUrl}
152175
</Text>
153176
</Box>
154177
);
155178

156179
case 'login':
157-
return <LoginForm apiUrl={apiUrl} onLogin={handleLogin} />;
180+
return (
181+
<LoginForm
182+
defaultAppUrl={currentAppUrl}
183+
onLogin={handleLogin}
184+
message={
185+
sessionExpired
186+
? 'Session expired — please log in again.'
187+
: undefined
188+
}
189+
/>
190+
);
158191

159192
case 'pick-source':
160193
return (

packages/cli/src/api/client.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,28 @@ import { loadSession, saveSession, clearSession } from '@/utils/config';
3131
// ------------------------------------------------------------------
3232

3333
interface ApiClientOptions {
34-
apiUrl: string;
34+
appUrl: string;
3535
}
3636

3737
export class ApiClient {
38+
private appUrl: string;
3839
private apiUrl: string;
3940
private cookies: string[] = [];
4041

4142
constructor(opts: ApiClientOptions) {
42-
this.apiUrl = opts.apiUrl.replace(/\/+$/, '');
43+
this.appUrl = opts.appUrl.replace(/\/+$/, '');
44+
this.apiUrl = `${this.appUrl}/api`;
4345

4446
const saved = loadSession();
45-
if (saved && saved.apiUrl === this.apiUrl) {
47+
if (saved && saved.appUrl === this.appUrl) {
4648
this.cookies = saved.cookies;
4749
}
4850
}
4951

52+
getAppUrl(): string {
53+
return this.appUrl;
54+
}
55+
5056
getApiUrl(): string {
5157
return this.apiUrl;
5258
}
@@ -58,20 +64,31 @@ export class ApiClient {
5864
// ---- Auth --------------------------------------------------------
5965

6066
async login(email: string, password: string): Promise<boolean> {
61-
const res = await fetch(`${this.apiUrl}/login/password`, {
62-
method: 'POST',
63-
headers: { 'Content-Type': 'application/json' },
64-
body: JSON.stringify({ email, password }),
65-
redirect: 'manual',
66-
});
67+
try {
68+
const res = await fetch(`${this.apiUrl}/login/password`, {
69+
method: 'POST',
70+
headers: { 'Content-Type': 'application/json' },
71+
body: JSON.stringify({ email, password }),
72+
redirect: 'manual',
73+
});
6774

68-
if (res.status === 302 || res.status === 200) {
69-
this.extractCookies(res);
70-
saveSession({ apiUrl: this.apiUrl, cookies: this.cookies });
71-
return true;
72-
}
75+
if (res.status === 302 || res.status === 200) {
76+
this.extractCookies(res);
77+
78+
// Verify the session is actually valid — some servers return
79+
// 302/200 without setting a real session (e.g. SSO redirects).
80+
if (!(await this.checkSession())) {
81+
return false;
82+
}
7383

74-
return false;
84+
saveSession({ appUrl: this.appUrl, cookies: this.cookies });
85+
return true;
86+
}
87+
88+
return false;
89+
} catch {
90+
return false;
91+
}
7592
}
7693

7794
async checkSession(): Promise<boolean> {

0 commit comments

Comments
 (0)