[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
This commit is contained in:
Warren Lee 2026-04-10 15:45:48 -07:00 committed by GitHub
parent 7a9882d421
commit 4dea3621a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 483 additions and 117 deletions

View file

@ -0,0 +1,10 @@
---
"@hyperdx/cli": minor
---
**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`.
- `hdx auth login` now prompts interactively for login method, app URL, and credentials (no flags required)
- Expired/missing sessions prompt for re-login with the last URL autofilled instead of printing an error
- Add URL input validation and post-login session verification
- Existing saved sessions are auto-migrated from `apiUrl` to `appUrl`

View file

@ -18,7 +18,7 @@ import EventViewer from '@/components/EventViewer';
type Screen = 'loading' | 'login' | 'pick-source' | 'events' | 'alerts';
interface AppProps {
apiUrl: string;
appUrl: string;
/** Pre-set search query from CLI flags */
query?: string;
/** Pre-set source name from CLI flags */
@ -27,9 +27,11 @@ interface AppProps {
follow?: boolean;
}
export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
export default function App({ appUrl, query, sourceName, follow }: AppProps) {
const [screen, setScreen] = useState<Screen>('loading');
const [client] = useState(() => new ApiClient({ apiUrl }));
const [client, setClient] = useState(() => new ApiClient({ appUrl }));
const [currentAppUrl, setCurrentAppUrl] = useState(appUrl);
const [sessionExpired, setSessionExpired] = useState(false);
const [eventSources, setLogSources] = useState<SourceResponse[]>([]);
const [savedSearches, setSavedSearches] = useState<SavedSearchResponse[]>([]);
const [selectedSource, setSelectedSource] = useState<SourceResponse | null>(
@ -43,18 +45,19 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
(async () => {
const valid = await client.checkSession();
if (valid) {
await loadData();
await loadData(client);
} else {
setSessionExpired(true);
setScreen('login');
}
})();
}, []);
const loadData = async () => {
const loadData = async (apiClient: ApiClient) => {
try {
const [sources, searches] = await Promise.all([
client.getSources(),
client.getSavedSearches().catch(() => [] as SavedSearchResponse[]),
apiClient.getSources(),
apiClient.getSavedSearches().catch(() => [] as SavedSearchResponse[]),
]);
const queryableSources = sources.filter(
@ -92,14 +95,34 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
setScreen('pick-source');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
const msg = err instanceof Error ? err.message : String(err);
// Treat auth errors as session issues — bounce back to login
if (msg.includes('401') || msg.includes('403')) {
setSessionExpired(true);
setScreen('login');
return;
}
setError(msg);
}
};
const handleLogin = async (email: string, password: string) => {
const ok = await client.login(email, password);
const handleLogin = async (
loginAppUrl: string,
email: string,
password: string,
) => {
// Recreate client if the user changed the URL
let activeClient = client;
if (loginAppUrl !== currentAppUrl) {
activeClient = new ApiClient({ appUrl: loginAppUrl });
setClient(activeClient);
setCurrentAppUrl(loginAppUrl);
}
const ok = await activeClient.login(email, password);
if (ok) {
await loadData();
setSessionExpired(false);
await loadData(activeClient);
}
return ok;
};
@ -148,13 +171,23 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
return (
<Box paddingX={1}>
<Text>
<Spinner type="dots" /> Connecting to {apiUrl}
<Spinner type="dots" /> Connecting to {currentAppUrl}
</Text>
</Box>
);
case 'login':
return <LoginForm apiUrl={apiUrl} onLogin={handleLogin} />;
return (
<LoginForm
defaultAppUrl={currentAppUrl}
onLogin={handleLogin}
message={
sessionExpired
? 'Session expired — please log in again.'
: undefined
}
/>
);
case 'pick-source':
return (

View file

@ -31,22 +31,28 @@ import { loadSession, saveSession, clearSession } from '@/utils/config';
// ------------------------------------------------------------------
interface ApiClientOptions {
apiUrl: string;
appUrl: string;
}
export class ApiClient {
private appUrl: string;
private apiUrl: string;
private cookies: string[] = [];
constructor(opts: ApiClientOptions) {
this.apiUrl = opts.apiUrl.replace(/\/+$/, '');
this.appUrl = opts.appUrl.replace(/\/+$/, '');
this.apiUrl = `${this.appUrl}/api`;
const saved = loadSession();
if (saved && saved.apiUrl === this.apiUrl) {
if (saved && saved.appUrl === this.appUrl) {
this.cookies = saved.cookies;
}
}
getAppUrl(): string {
return this.appUrl;
}
getApiUrl(): string {
return this.apiUrl;
}
@ -58,20 +64,31 @@ export class ApiClient {
// ---- Auth --------------------------------------------------------
async login(email: string, password: string): Promise<boolean> {
const res = await fetch(`${this.apiUrl}/login/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
redirect: 'manual',
});
try {
const res = await fetch(`${this.apiUrl}/login/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
redirect: 'manual',
});
if (res.status === 302 || res.status === 200) {
this.extractCookies(res);
saveSession({ apiUrl: this.apiUrl, cookies: this.cookies });
return true;
if (res.status === 302 || res.status === 200) {
this.extractCookies(res);
// Verify the session is actually valid — some servers return
// 302/200 without setting a real session (e.g. SSO redirects).
if (!(await this.checkSession())) {
return false;
}
saveSession({ appUrl: this.appUrl, cookies: this.cookies });
return true;
}
return false;
} catch {
return false;
}
return false;
}
async checkSession(): Promise<boolean> {

View file

@ -6,7 +6,7 @@
import { _origError } from '@/utils/silenceLogs';
import React, { useState, useCallback } from 'react';
import { render, Box, Text, useApp } from 'ink';
import { render, Box, Text, useApp, useInput } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import { Command } from 'commander';
@ -19,27 +19,83 @@ import { uploadSourcemaps } from '@/sourcemaps';
// ---- Standalone interactive login for `hdx auth login` -------------
/** Returns true if the string is a valid HTTP(S) URL. */
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
// Add new login methods here to extend the login flow.
const LOGIN_METHODS = [
{ id: 'password', label: 'Email / Password' },
// { id: 'oauth', label: 'OAuth / SSO' },
] as const;
type LoginMethod = (typeof LOGIN_METHODS)[number]['id'];
function LoginPrompt({
apiUrl,
client,
initialAppUrl,
initialClient,
}: {
apiUrl: string;
client: ApiClient;
initialAppUrl?: string;
initialClient?: ApiClient;
}) {
const { exit } = useApp();
const [field, setField] = useState<'email' | 'password'>('email');
const [field, setField] = useState<
'method' | 'appUrl' | 'email' | 'password'
>('method');
const [methodIdx, setMethodIdx] = useState(0);
const [_method, setMethod] = useState<LoginMethod | null>(null);
const [appUrl, setAppUrl] = useState(initialAppUrl ?? '');
const [client, setClient] = useState<ApiClient | null>(initialClient ?? null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Arrow-key navigation for login method picker
useInput(
(input, key) => {
if (field !== 'method') return;
if (key.upArrow || input === 'k') {
setMethodIdx(i => Math.max(0, i - 1));
}
if (key.downArrow || input === 'j') {
setMethodIdx(i => Math.min(LOGIN_METHODS.length - 1, i + 1));
}
if (key.return) {
const selected = LOGIN_METHODS[methodIdx];
setMethod(selected.id);
setField(initialAppUrl ? 'email' : 'appUrl');
}
},
{ isActive: field === 'method' },
);
const handleSubmitAppUrl = useCallback(() => {
const trimmed = appUrl.trim();
if (!trimmed) return;
if (!isValidUrl(trimmed)) {
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
return;
}
setError(null);
const c = new ApiClient({ appUrl: trimmed });
setClient(c);
setField('email');
}, [appUrl]);
const handleSubmitEmail = useCallback(() => {
if (!email.trim()) return;
setField('password');
}, [email]);
const handleSubmitPassword = useCallback(async () => {
if (!password) return;
if (!password || !client) return;
setLoading(true);
setError(null);
const ok = await client.login(email, password);
@ -49,23 +105,25 @@ function LoginPrompt({
// Small delay to let Ink unmount before writing to stdout
setTimeout(() => {
process.stdout.write(
chalk.green(`\nLogged in as ${email} (${apiUrl})\n`),
chalk.green(`\nLogged in as ${email} (${appUrl})\n`),
);
}, 50);
} else {
setError('Login failed. Check your email and password.');
setError('Login failed. Check your credentials and server URL.');
setField('email');
setEmail('');
setPassword('');
}
}, [email, password, client, apiUrl, exit]);
}, [email, password, client, appUrl, exit]);
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color="cyan">
HyperDX Login
</Text>
<Text dimColor>Server: {apiUrl}</Text>
{field !== 'method' && field !== 'appUrl' && (
<Text dimColor>Server: {appUrl}</Text>
)}
<Text> </Text>
{error && <Text color="red">{error}</Text>}
@ -74,6 +132,29 @@ function LoginPrompt({
<Text>
<Spinner type="dots" /> Logging in
</Text>
) : field === 'method' ? (
<Box flexDirection="column">
<Text>Login method:</Text>
<Text> </Text>
{LOGIN_METHODS.map((m, i) => (
<Text key={m.id} color={i === methodIdx ? 'green' : undefined}>
{i === methodIdx ? '▸ ' : ' '}
{m.label}
</Text>
))}
<Text> </Text>
<Text dimColor>/ to navigate, Enter to select</Text>
</Box>
) : field === 'appUrl' ? (
<Box>
<Text>HyperDX URL: </Text>
<TextInput
value={appUrl}
onChange={setAppUrl}
onSubmit={handleSubmitAppUrl}
placeholder="http://localhost:8080"
/>
</Box>
) : field === 'email' ? (
<Box>
<Text>Email: </Text>
@ -99,19 +180,196 @@ function LoginPrompt({
}
/**
* Resolve the server URL: use the provided flag, or fall back to the
* saved session's apiUrl. Exits with an error if neither is available.
* Resolve the app URL: use the provided flag, or fall back to the
* saved session's appUrl. Returns undefined if neither is available.
*/
function resolveServer(flagValue: string | undefined): string {
function resolveServer(flagValue: string | undefined): string | undefined {
if (flagValue) return flagValue;
const session = loadSession();
if (session?.apiUrl) return session.apiUrl;
_origError(
chalk.red(
`No server specified. Use ${chalk.bold('-s <url>')} or run ${chalk.bold('hdx auth login -s <url>')} first.\n`,
),
if (session?.appUrl) return session.appUrl;
return undefined;
}
/**
* Ensure the user has a valid session. If the session is expired or
* missing, launches the interactive LoginPrompt (with the last URL
* autofilled) and waits for re-authentication.
*
* Returns an authenticated ApiClient ready for use.
*/
async function ensureSession(
flagAppUrl: string | undefined,
): Promise<ApiClient> {
const appUrl = resolveServer(flagAppUrl);
// If we have an appUrl, try the existing session first
if (appUrl) {
const client = new ApiClient({ appUrl });
if (await client.checkSession()) {
return client;
}
// Session expired — show message and re-login
process.stderr.write(
chalk.yellow('Session expired — launching login…\n\n'),
);
}
// Launch interactive login prompt (appUrl autofilled if available)
return new Promise<ApiClient>(resolve => {
const { waitUntilExit } = render(
<ReLoginPrompt defaultAppUrl={appUrl} onAuthenticated={resolve} />,
);
// If the user ctrl-c's out of the prompt, exit the process
waitUntilExit().then(() => {
// If the promise was already resolved, this is a no-op.
// Otherwise the user quit without logging in.
});
});
}
/**
* Lightweight wrapper around LoginPrompt for non-TUI commands.
* Resolves the onAuthenticated callback with a ready ApiClient.
*/
function ReLoginPrompt({
defaultAppUrl,
onAuthenticated,
}: {
defaultAppUrl?: string;
onAuthenticated: (client: ApiClient) => void;
}) {
const { exit } = useApp();
const [field, setField] = useState<
'method' | 'appUrl' | 'email' | 'password'
>('method');
const [methodIdx, setMethodIdx] = useState(0);
const [_method, setMethod] = useState<LoginMethod | null>(null);
const [appUrl, setAppUrl] = useState(defaultAppUrl ?? '');
const [client, setClient] = useState<ApiClient | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useInput(
(input, key) => {
if (field !== 'method') return;
if (key.upArrow || input === 'k') {
setMethodIdx(i => Math.max(0, i - 1));
}
if (key.downArrow || input === 'j') {
setMethodIdx(i => Math.min(LOGIN_METHODS.length - 1, i + 1));
}
if (key.return) {
const selected = LOGIN_METHODS[methodIdx];
setMethod(selected.id);
setField('appUrl');
}
},
{ isActive: field === 'method' },
);
const handleSubmitAppUrl = useCallback(() => {
const trimmed = appUrl.trim();
if (!trimmed) return;
if (!isValidUrl(trimmed)) {
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
return;
}
setError(null);
const c = new ApiClient({ appUrl: trimmed });
setClient(c);
setField('email');
}, [appUrl]);
const handleSubmitEmail = useCallback(() => {
if (!email.trim()) return;
setField('password');
}, [email]);
const handleSubmitPassword = useCallback(async () => {
if (!password || !client) return;
setLoading(true);
setError(null);
const ok = await client.login(email, password);
setLoading(false);
if (ok) {
exit();
setTimeout(() => {
process.stdout.write(
chalk.green(`Logged in as ${email} (${appUrl})\n\n`),
);
onAuthenticated(client);
}, 50);
} else {
setError('Login failed. Check your credentials and server URL.');
setField('email');
setEmail('');
setPassword('');
}
}, [email, password, client, appUrl, exit, onAuthenticated]);
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color="cyan">
HyperDX Login
</Text>
{field !== 'method' && field !== 'appUrl' && (
<Text dimColor>Server: {appUrl}</Text>
)}
<Text> </Text>
{error && <Text color="red">{error}</Text>}
{loading ? (
<Text>
<Spinner type="dots" /> Logging in
</Text>
) : field === 'method' ? (
<Box flexDirection="column">
<Text>Login method:</Text>
<Text> </Text>
{LOGIN_METHODS.map((m, i) => (
<Text key={m.id} color={i === methodIdx ? 'green' : undefined}>
{i === methodIdx ? '▸ ' : ' '}
{m.label}
</Text>
))}
<Text> </Text>
<Text dimColor>/ to navigate, Enter to select</Text>
</Box>
) : field === 'appUrl' ? (
<Box>
<Text>HyperDX URL: </Text>
<TextInput
value={appUrl}
onChange={setAppUrl}
onSubmit={handleSubmitAppUrl}
placeholder="http://localhost:8080"
/>
</Box>
) : field === 'email' ? (
<Box>
<Text>Email: </Text>
<TextInput
value={email}
onChange={setEmail}
onSubmit={handleSubmitEmail}
/>
</Box>
) : (
<Box>
<Text>Password: </Text>
<TextInput
value={password}
onChange={setPassword}
onSubmit={handleSubmitPassword}
mask="*"
/>
</Box>
)}
</Box>
);
process.exit(1);
}
const program = new Command();
@ -127,20 +385,33 @@ program
program
.command('tui')
.description('Interactive TUI for event search and tail')
.option('-s, --server <url>', 'HyperDX API server URL')
.option('-a, --app-url <url>', 'HyperDX app URL')
.option('-q, --query <query>', 'Initial Lucene search query')
.option('--source <name>', 'Source name (skips picker)')
.option('-f, --follow', 'Start in follow/live tail mode')
.action(opts => {
const server = resolveServer(opts.server);
render(
<App
apiUrl={server}
query={opts.query}
sourceName={opts.source}
follow={opts.follow}
/>,
);
.action(async opts => {
const server = resolveServer(opts.appUrl);
if (!server) {
// No saved session and no -a flag — need to login first
const client = await ensureSession(undefined);
render(
<App
appUrl={client.getAppUrl()}
query={opts.query}
sourceName={opts.source}
follow={opts.follow}
/>,
);
} else {
render(
<App
appUrl={server}
query={opts.query}
sourceName={opts.source}
follow={opts.follow}
/>,
);
}
});
// ---- Auth (login / logout / status) --------------------------------
@ -154,27 +425,37 @@ const auth = program
auth
.command('login')
.description('Sign in to your HyperDX account')
.requiredOption('-s, --server <url>', 'HyperDX API server URL')
.option('-a, --app-url <url>', 'HyperDX app URL')
.option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password')
.action(async opts => {
const client = new ApiClient({ apiUrl: opts.server });
if (opts.email && opts.password) {
// Non-interactive login (for scripting/CI)
// Non-interactive login (for scripting/CI) — app URL is required
if (!opts.appUrl) {
_origError(
chalk.red(
`App URL is required for non-interactive login. Use ${chalk.bold('-a <url>')}.\n`,
),
);
process.exit(1);
}
const client = new ApiClient({ appUrl: opts.appUrl });
const ok = await client.login(opts.email, opts.password);
if (ok) {
process.stdout.write(
chalk.green(`Logged in as ${opts.email} (${opts.server})\n`),
chalk.green(`Logged in as ${opts.email} (${opts.appUrl})\n`),
);
} else {
_origError(chalk.red('Login failed. Check your email and password.\n'));
process.exit(1);
}
} else {
// Interactive login via Ink
// Interactive login via Ink — prompt for app URL if not provided
const client = opts.appUrl
? new ApiClient({ appUrl: opts.appUrl })
: undefined;
const { waitUntilExit } = render(
<LoginPrompt apiUrl={opts.server} client={client} />,
<LoginPrompt initialAppUrl={opts.appUrl} initialClient={client} />,
);
await waitUntilExit();
}
@ -196,19 +477,19 @@ auth
if (!session) {
process.stdout.write(
chalk.yellow(
`Not logged in. Run ${chalk.bold('hdx auth login -s <url>')} to sign in.\n`,
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
),
);
process.exit(1);
}
const client = new ApiClient({ apiUrl: session.apiUrl });
const client = new ApiClient({ appUrl: session.appUrl });
const ok = await client.checkSession();
if (!ok) {
process.stdout.write(
chalk.yellow(
`Session expired. Run ${chalk.bold('hdx auth login -s <url>')} to sign in again.\n`,
`Session expired. Run ${chalk.bold('hdx auth login')} to sign in again.\n`,
),
);
process.exit(1);
@ -217,10 +498,10 @@ auth
try {
const me = await client.getMe();
process.stdout.write(
`${chalk.green('Logged in')} as ${chalk.bold(me.email)} (${session.apiUrl})\n`,
`${chalk.green('Logged in')} as ${chalk.bold(me.email)} (${session.appUrl})\n`,
);
} catch {
process.stdout.write(chalk.green('Logged in') + ` (${session.apiUrl})\n`);
process.stdout.write(chalk.green('Logged in') + ` (${session.appUrl})\n`);
}
});
@ -231,7 +512,7 @@ program
.description(
'List data sources (log, trace, session, metric) with ClickHouse table schemas',
)
.option('-s, --server <url>', 'HyperDX API server URL')
.option('-a, --app-url <url>', 'HyperDX app URL')
.option('--json', 'Output as JSON (for programmatic consumption)')
.addHelpText(
'after',
@ -288,17 +569,7 @@ Examples:
`,
)
.action(async opts => {
const server = resolveServer(opts.server);
const client = new ApiClient({ apiUrl: server });
if (!(await client.checkSession())) {
_origError(
chalk.red(
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
),
);
process.exit(1);
}
const client = await ensureSession(opts.appUrl);
const sources = await client.getSources();
if (sources.length === 0) {
@ -398,7 +669,7 @@ Examples:
program
.command('dashboards')
.description('List dashboards with tile summaries')
.option('-s, --server <url>', 'HyperDX API server URL')
.option('-a, --app-url <url>', 'HyperDX app URL')
.option('--json', 'Output as JSON (for programmatic consumption)')
.addHelpText(
'after',
@ -432,17 +703,7 @@ Examples:
`,
)
.action(async opts => {
const server = resolveServer(opts.server);
const client = new ApiClient({ apiUrl: server });
if (!(await client.checkSession())) {
_origError(
chalk.red(
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
),
);
process.exit(1);
}
const client = await ensureSession(opts.appUrl);
const dashboards = await client.getDashboards();
if (dashboards.length === 0) {
@ -526,7 +787,7 @@ program
.description('Run a raw SQL query against a ClickHouse source')
.requiredOption('--source <nameOrId>', 'Source name or ID')
.requiredOption('--sql <query>', 'SQL query to execute')
.option('-s, --server <url>', 'HyperDX API server URL')
.option('-a, --app-url <url>', 'HyperDX app URL')
.option('--format <format>', 'ClickHouse output format', 'JSON')
.addHelpText(
'after',
@ -553,17 +814,7 @@ Examples:
`,
)
.action(async opts => {
const server = resolveServer(opts.server);
const client = new ApiClient({ apiUrl: server });
if (!(await client.checkSession())) {
_origError(
chalk.red(
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
),
);
process.exit(1);
}
const client = await ensureSession(opts.appUrl);
const sources = await client.getSources();
const source = sources.find(

View file

@ -6,19 +6,50 @@ import Spinner from 'ink-spinner';
import ErrorDisplay from '@/components/ErrorDisplay';
interface LoginFormProps {
apiUrl: string;
onLogin: (email: string, password: string) => Promise<boolean>;
/** Default app URL (autofilled, editable by the user). */
defaultAppUrl: string;
/** Called with the (possibly changed) appUrl, email, and password. */
onLogin: (
appUrl: string,
email: string,
password: string,
) => Promise<boolean>;
/** Optional message shown above the form (e.g. "Session expired"). */
message?: string;
}
type Field = 'email' | 'password';
type Field = 'appUrl' | 'email' | 'password';
export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
const [field, setField] = useState<Field>('email');
export default function LoginForm({
defaultAppUrl,
onLogin,
message,
}: LoginFormProps) {
const [field, setField] = useState<Field>('appUrl');
const [appUrl, setAppUrl] = useState(defaultAppUrl);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmitAppUrl = () => {
const trimmed = appUrl.trim();
if (!trimmed) return;
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
return;
}
} catch {
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
return;
}
setError(null);
setAppUrl(trimmed);
setField('email');
};
const handleSubmitEmail = () => {
if (!email.trim()) return;
setField('password');
@ -28,10 +59,10 @@ export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
if (!password) return;
setLoading(true);
setError(null);
const ok = await onLogin(email, password);
const ok = await onLogin(appUrl.trim(), email, password);
setLoading(false);
if (!ok) {
setError('Login failed. Check your email and password.');
setError('Login failed. Check your credentials and server URL.');
setField('email');
setEmail('');
setPassword('');
@ -43,7 +74,8 @@ export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
<Text bold color="cyan">
HyperDX TUI Login
</Text>
<Text dimColor>Server: {apiUrl}</Text>
{message && <Text color="yellow">{message}</Text>}
{field !== 'appUrl' && <Text dimColor>Server: {appUrl}</Text>}
<Text> </Text>
{error && <ErrorDisplay error={error} severity="error" compact />}
@ -52,6 +84,16 @@ export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
<Text>
<Spinner type="dots" /> Logging in
</Text>
) : field === 'appUrl' ? (
<Box>
<Text>HyperDX URL: </Text>
<TextInput
value={appUrl}
onChange={setAppUrl}
onSubmit={handleSubmitAppUrl}
placeholder="http://localhost:8080"
/>
</Box>
) : field === 'email' ? (
<Box>
<Text>Email: </Text>

View file

@ -6,7 +6,7 @@ const CONFIG_DIR = path.join(os.homedir(), '.config', 'hyperdx', 'cli');
const SESSION_FILE = path.join(CONFIG_DIR, 'session.json');
export interface SessionConfig {
apiUrl: string;
appUrl: string;
cookies: string[];
}
@ -27,7 +27,20 @@ export function loadSession(): SessionConfig | null {
try {
if (!fs.existsSync(SESSION_FILE)) return null;
const data = fs.readFileSync(SESSION_FILE, 'utf-8');
return JSON.parse(data) as SessionConfig;
const raw = JSON.parse(data) as Record<string, unknown>;
// Migrate legacy sessions that only have apiUrl (no appUrl).
// Old sessions stored the API URL directly; new sessions store
// the app URL and derive the API URL by appending '/api'.
if (!raw.appUrl && typeof raw.apiUrl === 'string') {
raw.appUrl = raw.apiUrl.replace(/\/api\/?$/, '');
delete raw.apiUrl;
const migrated = raw as unknown as SessionConfig;
saveSession(migrated);
return migrated;
}
return raw as unknown as SessionConfig;
} catch {
return null;
}