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;
}
const DEFAULT_HOST = 'https://api2.buster.so';
const LOCAL_HOST = 'http://localhost:3001';
const DEFAULT_HOST = 'api2.buster.so';
const LOCAL_HOST = 'localhost:3001';
export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
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 [hostInput, setHostInput] = useState('');
const [_error, setError] = useState<string | null>(null);
const [_existingCreds, setExistingCreds] = 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
useEffect(() => {
@ -45,50 +60,61 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
exit();
});
} else {
setStep('prompt');
}
}, [clear, exit]);
// Load existing credentials
useEffect(() => {
if (step === 'prompt') {
// Determine initial step based on provided flags
loadCredentials().then((creds) => {
setExistingCreds(creds);
// Determine the host to use
let targetHost = DEFAULT_HOST;
if (local) targetHost = LOCAL_HOST;
else if (cloud) targetHost = DEFAULT_HOST;
else if (host) targetHost = host;
else if (creds?.apiUrl) targetHost = creds.apiUrl;
let targetHost = '';
if (local) {
targetHost = LOCAL_HOST;
} else if (cloud) {
targetHost = DEFAULT_HOST;
} else if (host) {
targetHost = host;
}
setHostInput(targetHost);
setApiKeyInput(apiKey || '');
// If we have all required info from args, skip to validation
if (apiKey) {
setFinalCreds({ apiKey, apiUrl: targetHost });
// If we have both host and apiKey from flags, skip to validation
if ((targetHost || host) && apiKey) {
const finalHost = normalizeHost(targetHost || host || DEFAULT_HOST);
setFinalCreds({ apiKey, apiUrl: finalHost });
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
useInput((_input, key) => {
if (key.return && step === 'prompt') {
if (!hostInput) {
setError('Host URL is required');
return;
}
if (!apiKeyInput) {
if (key.return) {
if (step === 'host') {
// Use default host if empty
const finalHost = hostInput.trim() || DEFAULT_HOST;
setHostInput(finalHost);
setStep('apikey');
setPromptStage('apikey');
} else if (step === 'apikey') {
if (!apiKeyInput.trim()) {
setError('API key is required');
return;
}
setFinalCreds({ apiKey: apiKeyInput, apiUrl: hostInput });
const finalHost = normalizeHost(hostInput || DEFAULT_HOST);
setFinalCreds({ apiKey: apiKeyInput.trim(), apiUrl: finalHost });
setStep('validate');
}
}
});
// Validate credentials
@ -107,12 +133,12 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
setStep(noSave ? 'done' : 'save');
} else {
setError('Invalid API key');
setStep('prompt');
setStep('apikey');
}
})
.catch((err: Error) => {
setError(`Validation failed: ${err.message}`);
setStep('prompt');
setStep('apikey');
});
}
}, [step, finalCreds, noSave]);
@ -135,11 +161,13 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
useEffect(() => {
if (step === 'done' && finalCreds) {
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('Connection details:');
console.log(` host: ${finalCreds.apiUrl}`);
console.log(` api_key: ${masked}`);
console.log(` Host: ${displayHost}`);
console.log(` API Key: ${masked}`);
if (!noSave && step === 'done') {
console.log('\nCredentials saved successfully!');
@ -156,33 +184,48 @@ export function Auth({ apiKey, host, local, cloud, clear, noSave }: AuthProps) {
return <Text>Clearing credentials...</Text>;
}
if (step === 'prompt') {
if (step === 'host') {
return (
<Box flexDirection='column'>
{_existingCreds && (
<Box marginBottom={1}>
<Text color='yellow'> Existing credentials found. They will be overwritten.</Text>
</Box>
)}
{_error && <Text color='red'> {_error}</Text>}
<Box marginY={1}>
<Text>Enter the URL of your Buster API (default: {DEFAULT_HOST}): </Text>
<Box>
<Text>Enter your Buster API host (default: {DEFAULT_HOST}): </Text>
</Box>
<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>
</Box>
<TextInput value={apiKeyInput} onChange={setApiKeyInput} mask='*' />
<Box marginTop={1}>
<Text dimColor>
Find your API key at {hostInput || DEFAULT_HOST}/app/settings/api-keys
</Text>
<Text dimColor>Find your API key at https://{displayHost}/app/settings/api-keys</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter when ready to continue</Text>
<Text dimColor>Press Enter to authenticate</Text>
</Box>
</Box>
);

View File

@ -31,7 +31,7 @@
"@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*",
"@buster/web-tools": "workspace:*",
"@trigger.dev/sdk": "4.0.1",
"@trigger.dev/sdk": "4.0.2",
"ai": "catalog:",
"braintrust": "catalog:",
"drizzle-orm": "catalog:",
@ -39,6 +39,6 @@
"zod": "catalog:"
},
"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,
};
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 {
config: validatedConfig,
healthcheck: () => get(validatedConfig, '/api/healthcheck'),
healthcheck: () => get(validatedConfig, '/healthcheck'),
auth: {
validateApiKey: (apiKey?: string) => validateApiKey(validatedConfig, apiKey),
isApiKeyValid: (apiKey?: string) => isApiKeyValid(validatedConfig, apiKey),

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
// SDK Configuration Schema
export const SDKConfigSchema = z.object({
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),
retryAttempts: z.number().min(0).max(5).default(3),
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 { SDKConfig } from '../config';
import { post } from '../http';
import { get, post } from '../http';
/**
* Deploy semantic models to the Buster API
@ -10,7 +10,8 @@ export async function deployDatasets(
config: SDKConfig,
request: DeployRequest
): 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,
dataSourceId?: string
): Promise<{ datasets: unknown[] }> {
const path = dataSourceId ? `/api/v2/datasets?dataSourceId=${dataSourceId}` : '/api/v2/datasets';
const response = await fetch(new URL(path, config.apiUrl).toString(), {
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[] }>;
// The HTTP client will automatically add /api/v2 prefix
const params = dataSourceId ? { dataSourceId } : undefined;
return get<{ datasets: unknown[] }>(config, '/datasets', params);
}

View File

@ -1,9 +1,13 @@
import type { SDKConfig } from '../config';
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 {
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) {
Object.entries(params).forEach(([key, value]) => {