diff --git a/.changeset/cli-app-url-migration.md b/.changeset/cli-app-url-migration.md new file mode 100644 index 00000000..54012d64 --- /dev/null +++ b/.changeset/cli-app-url-migration.md @@ -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` diff --git a/packages/cli/src/App.tsx b/packages/cli/src/App.tsx index ddef3d3f..374e3249 100644 --- a/packages/cli/src/App.tsx +++ b/packages/cli/src/App.tsx @@ -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('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([]); const [savedSearches, setSavedSearches] = useState([]); const [selectedSource, setSelectedSource] = useState( @@ -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 ( - Connecting to {apiUrl}… + Connecting to {currentAppUrl}… ); case 'login': - return ; + return ( + + ); case 'pick-source': return ( diff --git a/packages/cli/src/api/client.ts b/packages/cli/src/api/client.ts index 942811c4..0832e755 100644 --- a/packages/cli/src/api/client.ts +++ b/packages/cli/src/api/client.ts @@ -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 { - 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 { diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 7e098063..4dbf9efa 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -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(null); + const [appUrl, setAppUrl] = useState(initialAppUrl ?? ''); + const [client, setClient] = useState(initialClient ?? null); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(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 ( HyperDX — Login - Server: {apiUrl} + {field !== 'method' && field !== 'appUrl' && ( + Server: {appUrl} + )} {error && {error}} @@ -74,6 +132,29 @@ function LoginPrompt({ Logging in… + ) : field === 'method' ? ( + + Login method: + + {LOGIN_METHODS.map((m, i) => ( + + {i === methodIdx ? '▸ ' : ' '} + {m.label} + + ))} + + ↑/↓ to navigate, Enter to select + + ) : field === 'appUrl' ? ( + + HyperDX URL: + + ) : field === 'email' ? ( Email: @@ -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 ')} or run ${chalk.bold('hdx auth login -s ')} 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 { + 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(resolve => { + const { waitUntilExit } = render( + , + ); + // 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(null); + const [appUrl, setAppUrl] = useState(defaultAppUrl ?? ''); + const [client, setClient] = useState(null); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + HyperDX — Login + + {field !== 'method' && field !== 'appUrl' && ( + Server: {appUrl} + )} + + + {error && {error}} + + {loading ? ( + + Logging in… + + ) : field === 'method' ? ( + + Login method: + + {LOGIN_METHODS.map((m, i) => ( + + {i === methodIdx ? '▸ ' : ' '} + {m.label} + + ))} + + ↑/↓ to navigate, Enter to select + + ) : field === 'appUrl' ? ( + + HyperDX URL: + + + ) : field === 'email' ? ( + + Email: + + + ) : ( + + Password: + + + )} + ); - 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 ', 'HyperDX API server URL') + .option('-a, --app-url ', 'HyperDX app URL') .option('-q, --query ', 'Initial Lucene search query') .option('--source ', 'Source name (skips picker)') .option('-f, --follow', 'Start in follow/live tail mode') - .action(opts => { - const server = resolveServer(opts.server); - render( - , - ); + .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( + , + ); + } else { + render( + , + ); + } }); // ---- Auth (login / logout / status) -------------------------------- @@ -154,27 +425,37 @@ const auth = program auth .command('login') .description('Sign in to your HyperDX account') - .requiredOption('-s, --server ', 'HyperDX API server URL') + .option('-a, --app-url ', 'HyperDX app URL') .option('-e, --email ', 'Email address') .option('-p, --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 ')}.\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( - , + , ); await waitUntilExit(); } @@ -196,19 +477,19 @@ auth if (!session) { process.stdout.write( chalk.yellow( - `Not logged in. Run ${chalk.bold('hdx auth login -s ')} 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 ')} 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 ', 'HyperDX API server URL') + .option('-a, --app-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 ', 'HyperDX API server URL') + .option('-a, --app-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 ', 'Source name or ID') .requiredOption('--sql ', 'SQL query to execute') - .option('-s, --server ', 'HyperDX API server URL') + .option('-a, --app-url ', 'HyperDX app URL') .option('--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( diff --git a/packages/cli/src/components/LoginForm.tsx b/packages/cli/src/components/LoginForm.tsx index d12bf29e..5ab74793 100644 --- a/packages/cli/src/components/LoginForm.tsx +++ b/packages/cli/src/components/LoginForm.tsx @@ -6,19 +6,50 @@ import Spinner from 'ink-spinner'; import ErrorDisplay from '@/components/ErrorDisplay'; interface LoginFormProps { - apiUrl: string; - onLogin: (email: string, password: string) => Promise; + /** 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; + /** 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('email'); +export default function LoginForm({ + defaultAppUrl, + onLogin, + message, +}: LoginFormProps) { + const [field, setField] = useState('appUrl'); + const [appUrl, setAppUrl] = useState(defaultAppUrl); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(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) { HyperDX TUI — Login - Server: {apiUrl} + {message && {message}} + {field !== 'appUrl' && Server: {appUrl}} {error && } @@ -52,6 +84,16 @@ export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) { Logging in… + ) : field === 'appUrl' ? ( + + HyperDX URL: + + ) : field === 'email' ? ( Email: diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index bcd738bd..6740380b 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -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; + + // 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; }