This commit is contained in:
dal 2025-09-03 09:41:52 -06:00
parent e165a37b36
commit b5e931dcb8
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
7 changed files with 111 additions and 75 deletions

View File

@ -20,17 +20,32 @@ interface AuthProps {
noSave?: boolean; noSave?: boolean;
} }
const DEFAULT_HOST = 'https://api2.buster.so'; const DEFAULT_HOST = 'api2.buster.so';
const LOCAL_HOST = 'http://localhost:3001'; const LOCAL_HOST = 'localhost:3001';
export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) { export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
const { exit } = useApp(); const { exit } = useApp();
const [step, setStep] = useState<'clear' | 'prompt' | 'validate' | 'save' | 'done'>('clear'); const [step, setStep] = useState<'clear' | 'host' | 'apikey' | 'validate' | 'save' | 'done'>(
'clear'
);
const [apiKeyInput, setApiKeyInput] = useState(''); const [apiKeyInput, setApiKeyInput] = useState('');
const [hostInput, setHostInput] = useState(''); const [hostInput, setHostInput] = useState('');
const [_error, setError] = useState<string | null>(null); const [_error, setError] = useState<string | null>(null);
const [_existingCreds, setExistingCreds] = useState<Credentials | null>(null); const [_existingCreds, setExistingCreds] = useState<Credentials | null>(null);
const [finalCreds, setFinalCreds] = useState<Credentials | null>(null); const [finalCreds, setFinalCreds] = useState<Credentials | null>(null);
const [_promptStage, setPromptStage] = useState<'host' | 'apikey'>('host');
// Normalize host URL (add https:// if missing, unless localhost)
const normalizeHost = (h: string): string => {
const trimmed = h.trim();
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
if (trimmed.includes('localhost') || trimmed.includes('127.0.0.1')) {
return `http://${trimmed}`;
}
return `https://${trimmed}`;
};
// Handle clear flag // Handle clear flag
useEffect(() => { useEffect(() => {
@ -45,50 +60,61 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
exit(); exit();
}); });
} else { } else {
setStep('prompt'); // Determine initial step based on provided flags
}
}, [clear, exit]);
// Load existing credentials
useEffect(() => {
if (step === 'prompt') {
loadCredentials().then((creds) => { loadCredentials().then((creds) => {
setExistingCreds(creds); setExistingCreds(creds);
// Determine the host to use // Determine the host to use
let targetHost = DEFAULT_HOST; let targetHost = '';
if (local) targetHost = LOCAL_HOST; if (local) {
else if (cloud) targetHost = DEFAULT_HOST; targetHost = LOCAL_HOST;
else if (host) targetHost = host; } else if (cloud) {
else if (creds?.apiUrl) targetHost = creds.apiUrl; targetHost = DEFAULT_HOST;
} else if (host) {
targetHost = host;
}
setHostInput(targetHost); // If we have both host and apiKey from flags, skip to validation
setApiKeyInput(apiKey || ''); if ((targetHost || host) && apiKey) {
const finalHost = normalizeHost(targetHost || host || DEFAULT_HOST);
// If we have all required info from args, skip to validation setFinalCreds({ apiKey, apiUrl: finalHost });
if (apiKey) {
setFinalCreds({ apiKey, apiUrl: targetHost });
setStep('validate'); setStep('validate');
} else if (targetHost || host) {
// If we only have host, set it and prompt for API key
setHostInput(targetHost || host || '');
setApiKeyInput(apiKey || '');
setStep('apikey');
setPromptStage('apikey');
} else {
// No flags provided, start with host prompt
setHostInput('');
setApiKeyInput(apiKey || '');
setStep('host');
setPromptStage('host');
} }
}); });
} }
}, [step, apiKey, host, local, cloud]); }, [clear, apiKey, host, local, cloud, exit]);
// Handle input completion // Handle input completion
useInput((_input, key) => { useInput((_input, key) => {
if (key.return && step === 'prompt') { if (key.return) {
if (!hostInput) { if (step === 'host') {
setError('Host URL is required'); // Use default host if empty
return; const finalHost = hostInput.trim() || DEFAULT_HOST;
} setHostInput(finalHost);
if (!apiKeyInput) { setStep('apikey');
setPromptStage('apikey');
} else if (step === 'apikey') {
if (!apiKeyInput.trim()) {
setError('API key is required'); setError('API key is required');
return; return;
} }
const finalHost = normalizeHost(hostInput || DEFAULT_HOST);
setFinalCreds({ apiKey: apiKeyInput, apiUrl: hostInput }); setFinalCreds({ apiKey: apiKeyInput.trim(), apiUrl: finalHost });
setStep('validate'); setStep('validate');
} }
}
}); });
// Validate credentials // Validate credentials
@ -107,12 +133,12 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
setStep(noSave ? 'done' : 'save'); setStep(noSave ? 'done' : 'save');
} else { } else {
setError('Invalid API key'); setError('Invalid API key');
setStep('prompt'); setStep('apikey');
} }
}) })
.catch((err: Error) => { .catch((err: Error) => {
setError(`Validation failed: ${err.message}`); setError(`Validation failed: ${err.message}`);
setStep('prompt'); setStep('apikey');
}); });
} }
}, [step, finalCreds, noSave]); }, [step, finalCreds, noSave]);
@ -135,11 +161,13 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
useEffect(() => { useEffect(() => {
if (step === 'done' && finalCreds) { if (step === 'done' && finalCreds) {
const masked = finalCreds.apiKey.length > 6 ? `****${finalCreds.apiKey.slice(-6)}` : '****'; const masked = finalCreds.apiKey.length > 6 ? `****${finalCreds.apiKey.slice(-6)}` : '****';
// Remove protocol for display
const displayHost = finalCreds.apiUrl.replace(/^https?:\/\//, '');
console.log("\n✅ You've successfully connected to Buster!\n"); console.log("\n✅ You've successfully connected to Buster!\n");
console.log('Connection details:'); console.log('Connection details:');
console.log(` host: ${finalCreds.apiUrl}`); console.log(` Host: ${displayHost}`);
console.log(` api_key: ${masked}`); console.log(` API Key: ${masked}`);
if (!noSave && step === 'done') { if (!noSave && step === 'done') {
console.log('\nCredentials saved successfully!'); console.log('\nCredentials saved successfully!');
@ -156,33 +184,48 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
return <Text>Clearing credentials...</Text>; return <Text>Clearing credentials...</Text>;
} }
if (step === 'prompt') { if (step === 'host') {
return ( return (
<Box flexDirection='column'> <Box flexDirection='column'>
{_existingCreds && ( {_existingCreds && (
<Box marginBottom={1}>
<Text color='yellow'> Existing credentials found. They will be overwritten.</Text> <Text color='yellow'> Existing credentials found. They will be overwritten.</Text>
</Box>
)} )}
{_error && <Text color='red'> {_error}</Text>} <Box>
<Text>Enter your Buster API host (default: {DEFAULT_HOST}): </Text>
<Box marginY={1}>
<Text>Enter the URL of your Buster API (default: {DEFAULT_HOST}): </Text>
</Box> </Box>
<TextInput value={hostInput} onChange={setHostInput} placeholder={DEFAULT_HOST} /> <TextInput value={hostInput} onChange={setHostInput} placeholder={DEFAULT_HOST} />
<Box marginY={1}> <Box marginTop={1}>
<Text dimColor>Press Enter to continue (leave empty for default)</Text>
</Box>
</Box>
);
}
if (step === 'apikey') {
const displayHost = (hostInput || DEFAULT_HOST).replace(/^https?:\/\//, '');
return (
<Box flexDirection='column'>
{_error && (
<Box marginBottom={1}>
<Text color='red'> {_error}</Text>
</Box>
)}
<Box>
<Text>Enter your API key: </Text> <Text>Enter your API key: </Text>
</Box> </Box>
<TextInput value={apiKeyInput} onChange={setApiKeyInput} mask='*' /> <TextInput value={apiKeyInput} onChange={setApiKeyInput} mask='*' />
<Box marginTop={1}> <Box marginTop={1}>
<Text dimColor> <Text dimColor>Find your API key at https://{displayHost}/app/settings/api-keys</Text>
Find your API key at {hostInput || DEFAULT_HOST}/app/settings/api-keys
</Text>
</Box> </Box>
<Box marginTop={1}> <Box marginTop={1}>
<Text dimColor>Press Enter when ready to continue</Text> <Text dimColor>Press Enter to authenticate</Text>
</Box> </Box>
</Box> </Box>
); );

View File

@ -31,7 +31,7 @@
"@buster/typescript-config": "workspace:*", "@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*", "@buster/vitest-config": "workspace:*",
"@buster/web-tools": "workspace:*", "@buster/web-tools": "workspace:*",
"@trigger.dev/sdk": "4.0.1", "@trigger.dev/sdk": "4.0.2",
"ai": "catalog:", "ai": "catalog:",
"braintrust": "catalog:", "braintrust": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
@ -39,6 +39,6 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@trigger.dev/build": "4.0.1" "@trigger.dev/build": "4.0.2"
} }
} }

View File

@ -16,9 +16,10 @@ export async function validateApiKey(
apiKey: apiKey || config.apiKey, apiKey: apiKey || config.apiKey,
}; };
console.info(`Validating API key with endpoint: ${config.apiUrl}/api/v2/auth/validate-api-key`); // The HTTP client will automatically add /api/v2 prefix
console.info(`Validating API key with host: ${config.apiUrl}`);
return post<ValidateApiKeyResponse>(config, '/api/v2/auth/validate-api-key', request); return post<ValidateApiKeyResponse>(config, '/auth/validate-api-key', request);
} }
/** /**

View File

@ -25,7 +25,7 @@ export function createBusterSDK(config: Partial<SDKConfig>): BusterSDK {
return { return {
config: validatedConfig, config: validatedConfig,
healthcheck: () => get(validatedConfig, '/api/healthcheck'), healthcheck: () => get(validatedConfig, '/healthcheck'),
auth: { auth: {
validateApiKey: (apiKey?: string) => validateApiKey(validatedConfig, apiKey), validateApiKey: (apiKey?: string) => validateApiKey(validatedConfig, apiKey),
isApiKeyValid: (apiKey?: string) => isApiKeyValid(validatedConfig, apiKey), isApiKeyValid: (apiKey?: string) => isApiKeyValid(validatedConfig, apiKey),

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
// SDK Configuration Schema // SDK Configuration Schema
export const SDKConfigSchema = z.object({ export const SDKConfigSchema = z.object({
apiKey: z.string().min(1, 'API key is required'), apiKey: z.string().min(1, 'API key is required'),
apiUrl: z.string().url().default('https://api2.buster.so'), apiUrl: z.string().url().default('https://api2.buster.so'), // Base URL without /api/v2
timeout: z.number().min(1000).max(60000).default(30000), timeout: z.number().min(1000).max(60000).default(30000),
retryAttempts: z.number().min(0).max(5).default(3), retryAttempts: z.number().min(0).max(5).default(3),
retryDelay: z.number().min(100).max(5000).default(1000), retryDelay: z.number().min(100).max(5000).default(1000),

View File

@ -1,6 +1,6 @@
import type { DeployRequest, DeployResponse } from '@buster/server-shared'; import type { DeployRequest, DeployResponse } from '@buster/server-shared';
import type { SDKConfig } from '../config'; import type { SDKConfig } from '../config';
import { post } from '../http'; import { get, post } from '../http';
/** /**
* Deploy semantic models to the Buster API * Deploy semantic models to the Buster API
@ -10,7 +10,8 @@ export async function deployDatasets(
config: SDKConfig, config: SDKConfig,
request: DeployRequest request: DeployRequest
): Promise<DeployResponse> { ): Promise<DeployResponse> {
return post<DeployResponse>(config, '/api/v2/datasets/deploy', request); // The HTTP client will automatically add /api/v2 prefix
return post<DeployResponse>(config, '/datasets/deploy', request);
} }
/** /**
@ -20,20 +21,7 @@ export async function getDatasets(
config: SDKConfig, config: SDKConfig,
dataSourceId?: string dataSourceId?: string
): Promise<{ datasets: unknown[] }> { ): Promise<{ datasets: unknown[] }> {
const path = dataSourceId ? `/api/v2/datasets?dataSourceId=${dataSourceId}` : '/api/v2/datasets'; // The HTTP client will automatically add /api/v2 prefix
const params = dataSourceId ? { dataSourceId } : undefined;
const response = await fetch(new URL(path, config.apiUrl).toString(), { return get<{ datasets: unknown[] }>(config, '/datasets', params);
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
...config.headers,
},
});
if (!response.ok) {
throw new Error(`Failed to get datasets: ${response.statusText}`);
}
return response.json() as Promise<{ datasets: unknown[] }>;
} }

View File

@ -1,9 +1,13 @@
import type { SDKConfig } from '../config'; import type { SDKConfig } from '../config';
import { NetworkError, SDKError } from '../errors'; import { NetworkError, SDKError } from '../errors';
// Build URL with query params // Build URL with query params and /api/v2 prefix for Buster API routes
export function buildUrl(baseUrl: string, path: string, params?: Record<string, string>): string { export function buildUrl(baseUrl: string, path: string, params?: Record<string, string>): string {
const url = new URL(path, baseUrl); // If the path doesn't start with /api, assume it's a Buster API route and add /api/v2 prefix
const finalPath = path.startsWith('/api')
? path
: `/api/v2${path.startsWith('/') ? path : `/${path}`}`;
const url = new URL(finalPath, baseUrl);
if (params) { if (params) {
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {