Enhance CLI functionality and SDK integration

- Added new CLI commands for authentication management and improved default action handling.
- Updated package.json to include new dependencies and scripts for building and running the CLI.
- Integrated authentication features into the SDK, allowing for API key validation.
- Modified the SDK configuration to use a new default API URL.
- Expanded the server API to include authentication routes.
- Updated database queries to support new API key functionalities.
- Improved code structure and readability across CLI and SDK components.
This commit is contained in:
dal 2025-08-27 23:31:03 -06:00
parent 3b585d09dd
commit 5726b01ecc
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
29 changed files with 2127 additions and 664 deletions

View File

@ -23,25 +23,31 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@buster/sdk": "workspace:*",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ink": "^5.0.1",
"ink-big-text": "^2.0.0",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"react": "^18.3.1",
"zod": "^3.24.1",
"chalk": "^5.3.0"
"zod": "^3.24.1"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@buster/env-utils": "workspace:*",
"@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*",
"@buster/env-utils": "workspace:*",
"@types/bun": "^1.1.14",
"@types/node": "^22.10.5",
"@types/react": "^18.3.17",
"@biomejs/biome": "^1.9.4",
"typescript": "^5.7.3",
"tsx": "^4.19.2",
"vitest": "^2.1.8",
"@vitest/coverage-v8": "^2.1.8",
"ink-testing-library": "^4.0.0"
"ink-testing-library": "^4.0.0",
"react-devtools-core": "^6.1.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
},
"engines": {
"bun": ">=1.0.0"

View File

@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import { Text, Box, useInput, useApp } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import { createBusterSDK } from '@buster/sdk';
import {
saveCredentials,
loadCredentials,
deleteCredentials,
getCredentials,
type Credentials,
} from '../utils/credentials.js';
interface AuthProps {
apiKey?: string;
host?: string;
local?: boolean;
cloud?: boolean;
clear?: boolean;
noSave?: boolean;
}
const DEFAULT_HOST = 'https://api2.buster.so';
const LOCAL_HOST = 'http://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 [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);
// Handle clear flag
useEffect(() => {
if (clear) {
deleteCredentials()
.then(() => {
console.log('✅ Credentials cleared successfully');
exit();
})
.catch((err: Error) => {
console.error('❌ Failed to clear credentials:', err.message);
exit();
});
} else {
setStep('prompt');
}
}, [clear, exit]);
// Load existing credentials
useEffect(() => {
if (step === 'prompt') {
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;
setHostInput(targetHost);
setApiKeyInput(apiKey || '');
// If we have all required info from args, skip to validation
if (apiKey) {
setFinalCreds({ apiKey, apiUrl: targetHost });
setStep('validate');
}
});
}
}, [step, apiKey, host, local, cloud]);
// Handle input completion
useInput((input, key) => {
if (key.return && step === 'prompt') {
if (!hostInput) {
setError('Host URL is required');
return;
}
if (!apiKeyInput) {
setError('API key is required');
return;
}
setFinalCreds({ apiKey: apiKeyInput, apiUrl: hostInput });
setStep('validate');
}
});
// Validate credentials
useEffect(() => {
if (step === 'validate' && finalCreds) {
const sdk = createBusterSDK({
apiKey: finalCreds.apiKey,
apiUrl: finalCreds.apiUrl,
timeout: 30000,
});
sdk.auth.isApiKeyValid()
.then((valid: boolean) => {
if (valid) {
setStep(noSave ? 'done' : 'save');
} else {
setError('Invalid API key');
setStep('prompt');
}
})
.catch((err: Error) => {
setError(`Validation failed: ${err.message}`);
setStep('prompt');
});
}
}, [step, finalCreds, noSave]);
// Save credentials
useEffect(() => {
if (step === 'save' && finalCreds) {
saveCredentials(finalCreds)
.then(() => {
setStep('done');
})
.catch((err: Error) => {
setError(`Failed to save: ${err.message}`);
setStep('done');
});
}
}, [step, finalCreds]);
// Display success and exit
useEffect(() => {
if (step === 'done' && finalCreds) {
const masked = finalCreds.apiKey.length > 6
? `****${finalCreds.apiKey.slice(-6)}`
: '****';
console.log('\n✅ You\'ve successfully connected to Buster!\n');
console.log('Connection details:');
console.log(` host: ${finalCreds.apiUrl}`);
console.log(` api_key: ${masked}`);
if (!noSave && step === 'done') {
console.log('\nCredentials saved successfully!');
} else if (noSave) {
console.log('\nNote: Credentials were not saved due to --no-save flag');
}
exit();
}
}, [step, finalCreds, noSave, exit]);
// Render based on current step
if (step === 'clear') {
return <Text>Clearing credentials...</Text>;
}
if (step === 'prompt') {
return (
<Box flexDirection="column">
{existingCreds && (
<Text color="yellow">
Existing credentials found. They will be overwritten.
</Text>
)}
{error && (
<Text color="red"> {error}</Text>
)}
<Box marginY={1}>
<Text>Enter the URL of your Buster API (default: {DEFAULT_HOST}): </Text>
</Box>
<TextInput
value={hostInput}
onChange={setHostInput}
placeholder={DEFAULT_HOST}
/>
<Box marginY={1}>
<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>
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter when ready to continue</Text>
</Box>
</Box>
);
}
if (step === 'validate') {
return (
<Box>
<Text>
<Spinner type="dots" />
{' '}Validating API key...
</Text>
</Box>
);
}
if (step === 'save') {
return (
<Box>
<Text>
<Spinner type="dots" />
{' '}Saving credentials...
</Text>
</Box>
);
}
return null;
}

View File

@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import { Text, Box, useInput, useApp } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import BigText from 'ink-big-text';
import { createBusterSDK } from '@buster/sdk';
import {
saveCredentials,
hasCredentials,
type Credentials,
} from '../utils/credentials.js';
interface InitProps {
apiKey?: string;
host?: string;
local?: boolean;
skipBanner?: boolean;
}
const DEFAULT_HOST = 'https://api2.buster.so';
const LOCAL_HOST = 'http://localhost:3001';
// Component for the welcome screen
function WelcomeScreen() {
return (
<Box paddingY={2} paddingX={2} alignItems="center">
<Box marginRight={4}>
<Text color="#7C3AED">
<BigText text="BUSTER" font="block" />
</Text>
</Box>
<Box flexDirection="column" justifyContent="center">
<Text bold>Welcome to Buster</Text>
<Box marginTop={1}>
<Text dimColor>Type / to use slash commands</Text>
</Box>
<Box>
<Text dimColor>Type @ to mention files</Text>
</Box>
<Box>
<Text dimColor>Ctrl-C to exit</Text>
</Box>
<Box marginTop={2}>
<Text dimColor>/help for more</Text>
</Box>
<Box marginTop={2}>
<Text color="#7C3AED">"Run `buster` and fix all the errors"</Text>
</Box>
</Box>
</Box>
);
}
export function Init({ apiKey, host, local, skipBanner }: InitProps) {
const { exit } = useApp();
const [step, setStep] = useState<'check' | 'prompt' | 'validate' | 'save' | 'done'>('check');
const [apiKeyInput, setApiKeyInput] = useState('');
const [hostInput, setHostInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [finalCreds, setFinalCreds] = useState<Credentials | null>(null);
const [showBanner] = useState(!skipBanner);
// Check for existing credentials
useEffect(() => {
if (step === 'check') {
hasCredentials().then(hasCreds => {
if (hasCreds) {
console.log('\n✅ You already have Buster configured!');
console.log('\nTo reconfigure, run: buster auth');
exit();
} else {
// Set default host based on flags
let targetHost = DEFAULT_HOST;
if (local) targetHost = LOCAL_HOST;
else if (host) targetHost = host;
setHostInput(targetHost);
setApiKeyInput(apiKey || '');
// If API key provided via args, skip to validation
if (apiKey) {
setFinalCreds({ apiKey, apiUrl: targetHost });
setStep('validate');
} else {
setStep('prompt');
}
}
});
}
}, [step, apiKey, host, local, exit]);
// Handle input
useInput((input, key) => {
if (key.return && step === 'prompt' && apiKeyInput) {
setFinalCreds({
apiKey: apiKeyInput,
apiUrl: hostInput || DEFAULT_HOST
});
setStep('validate');
}
});
// Validate API key
useEffect(() => {
if (step === 'validate' && finalCreds) {
const sdk = createBusterSDK({
apiKey: finalCreds.apiKey,
apiUrl: finalCreds.apiUrl,
timeout: 30000,
});
sdk.auth.isApiKeyValid()
.then((valid: boolean) => {
if (valid) {
setStep('save');
} else {
setError('Invalid API key. Please check your key and try again.');
setStep('prompt');
setApiKeyInput('');
}
})
.catch((err: Error) => {
setError(`Connection failed: ${err.message}`);
setStep('prompt');
});
}
}, [step, finalCreds]);
// Save credentials
useEffect(() => {
if (step === 'save' && finalCreds) {
saveCredentials(finalCreds)
.then(() => {
setStep('done');
})
.catch((err: Error) => {
console.error('Failed to save credentials:', err.message);
setStep('done');
});
}
}, [step, finalCreds]);
// Show success message and exit
useEffect(() => {
if (step === 'done' && finalCreds) {
const masked = finalCreds.apiKey.length > 6
? `****${finalCreds.apiKey.slice(-6)}`
: '****';
console.log('\n🎉 Welcome to Buster!\n');
console.log('✅ You\'ve successfully connected to Buster!\n');
console.log('Connection details:');
console.log(` host: ${finalCreds.apiUrl}`);
console.log(` api_key: ${masked}`);
console.log('\nYour credentials have been saved.');
console.log('\n📚 Get started:');
console.log(' buster --help Show available commands');
console.log(' buster auth Reconfigure authentication');
exit();
}
}, [step, finalCreds, exit]);
// Render based on step - always show welcome screen at the top if enabled
return (
<Box flexDirection="column">
{showBanner && <WelcomeScreen />}
{step === 'check' && (
<Box>
<Text>
<Spinner type="dots" />
{' '}Checking configuration...
</Text>
</Box>
)}
{step === 'prompt' && (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text>Let's get you connected to Buster.</Text>
</Box>
{error && (
<Box marginBottom={1}>
<Text color="red"> {error}</Text>
</Box>
)}
{!apiKey && !host && !local && (
<>
<Box marginBottom={1}>
<Text>API URL: {hostInput}</Text>
<Text dimColor> (Press Enter to use default)</Text>
</Box>
</>
)}
<Box marginBottom={1}>
<Text>Enter your API key: </Text>
</Box>
<TextInput
value={apiKeyInput}
onChange={setApiKeyInput}
mask="*"
placeholder="sk_..."
/>
<Box marginTop={1}>
<Text dimColor>
Find your API key at {hostInput}/app/settings/api-keys
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter to continue</Text>
</Box>
</Box>
)}
{step === 'validate' && (
<Box>
<Text>
<Spinner type="dots" />
{' '}Validating your API key...
</Text>
</Box>
)}
{step === 'save' && (
<Box>
<Text>
<Spinner type="dots" />
{' '}Saving your configuration...
</Text>
</Box>
)}
</Box>
);
}

View File

@ -43,7 +43,7 @@ export const InteractiveCommand: React.FC = () => {
<Box marginTop={1} flexDirection="column">
{options.map((option, index) => (
<Box key={option}>
<Text color={selectedOption === index ? 'green' : undefined}>
<Text {...(selectedOption === index ? { color: 'green' } : {})}>
{selectedOption === index ? chalk.bold('▶ ') : ' '}
{option}
</Text>

View File

@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
import { Text, Box, useInput, useApp } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import BigText from 'ink-big-text';
import { createBusterSDK } from '@buster/sdk';
import {
saveCredentials,
getCredentials,
hasCredentials,
type Credentials,
} from '../utils/credentials.js';
const DEFAULT_HOST = 'https://api2.buster.so';
const LOCAL_HOST = 'http://localhost:3001';
// Component for the welcome screen header
function WelcomeHeader() {
return (
<Box paddingY={2} paddingX={2} alignItems="center">
<Box marginRight={4}>
<Text color="#7C3AED">
<BigText text="BUSTER" font="block" />
</Text>
</Box>
<Box flexDirection="column" justifyContent="center">
<Text bold>Welcome to Buster</Text>
<Box marginTop={1}>
<Text dimColor>Type / to use slash commands</Text>
</Box>
<Box>
<Text dimColor>Type @ to mention files</Text>
</Box>
<Box>
<Text dimColor>Ctrl-C to exit</Text>
</Box>
<Box marginTop={2}>
<Text dimColor>/help for more</Text>
</Box>
<Box marginTop={2}>
<Text color="#7C3AED">"Run `buster` and fix all the errors"</Text>
</Box>
</Box>
</Box>
);
}
// Input box component for authenticated users
function CommandInput({ onSubmit }: { onSubmit: (input: string) => void }) {
const [input, setInput] = useState('');
const handleSubmit = () => {
if (input.trim()) {
onSubmit(input);
setInput('');
}
};
return (
<Box paddingX={2} paddingBottom={1}>
<Box borderStyle="single" borderColor="#7C3AED" paddingX={1} width="100%">
<Text color="#7C3AED"> </Text>
<TextInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
placeholder="Enter a command or question..."
/>
</Box>
</Box>
);
}
// Auth prompt component for unauthenticated users
function AuthPrompt({ onAuth }: { onAuth: (creds: Credentials) => void }) {
const [apiKey, setApiKey] = useState('');
const [host] = useState(DEFAULT_HOST);
const [error, setError] = useState<string | null>(null);
const [validating, setValidating] = useState(false);
const handleSubmit = async () => {
if (!apiKey.trim()) {
setError('API key is required');
return;
}
setValidating(true);
setError(null);
try {
const sdk = createBusterSDK({
apiKey: apiKey.trim(),
apiUrl: host,
timeout: 30000,
});
const isValid = await sdk.auth.isApiKeyValid();
if (isValid) {
const creds = { apiKey: apiKey.trim(), apiUrl: host };
await saveCredentials(creds);
onAuth(creds);
} else {
setError('Invalid API key. Please check your key and try again.');
setApiKey('');
}
} catch (err) {
setError(`Connection failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setValidating(false);
}
};
if (validating) {
return (
<Box paddingX={2}>
<Text>
<Spinner type="dots" />
{' '}Validating your API key...
</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingX={2}>
<Box marginBottom={1}>
<Text>Let's get you connected to Buster.</Text>
</Box>
{error && (
<Box marginBottom={1}>
<Text color="red"> {error}</Text>
</Box>
)}
<Box marginBottom={1}>
<Text>Enter your API key: </Text>
</Box>
<Box borderStyle="single" borderColor="#7C3AED" paddingX={1}>
<TextInput
value={apiKey}
onChange={setApiKey}
onSubmit={handleSubmit}
mask="*"
placeholder="sk_..."
/>
</Box>
<Box marginTop={1}>
<Text dimColor>
Find your API key at {host}/app/settings/api-keys
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter to continue</Text>
</Box>
</Box>
);
}
export function Main() {
const { exit } = useApp();
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [credentials, setCredentials] = useState<Credentials | null>(null);
const [checkingAuth, setCheckingAuth] = useState(true);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
// Check authentication status on mount
useEffect(() => {
const checkAuth = async () => {
try {
const creds = await getCredentials();
if (creds && creds.apiKey) {
// Validate the stored credentials
const sdk = createBusterSDK({
apiKey: creds.apiKey,
apiUrl: creds.apiUrl,
timeout: 30000,
});
const isValid = await sdk.auth.isApiKeyValid();
if (isValid) {
setCredentials(creds);
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
} else {
setIsAuthenticated(false);
}
} catch (error) {
setIsAuthenticated(false);
} finally {
setCheckingAuth(false);
}
};
checkAuth();
}, []);
// Handle keyboard shortcuts
useInput((input, key) => {
if (key.ctrl && input === 'c') {
exit();
}
});
const handleAuth = (creds: Credentials) => {
setCredentials(creds);
setIsAuthenticated(true);
};
const handleCommand = (command: string) => {
setCommandHistory([...commandHistory, command]);
// Handle special commands
if (command === '/help') {
console.info('\nAvailable commands:');
console.info(' /help - Show this help message');
console.info(' /clear - Clear the screen');
console.info(' /exit - Exit the CLI');
console.info('\nFor more information, visit https://docs.buster.so');
} else if (command === '/clear') {
console.clear();
} else if (command === '/exit') {
exit();
} else {
// TODO: Process the command with Buster
console.info(`Processing: ${command}`);
console.info('Command processing not yet implemented.');
}
};
if (checkingAuth) {
return (
<Box flexDirection="column">
<WelcomeHeader />
<Box paddingX={2}>
<Text>
<Spinner type="dots" />
{' '}Checking configuration...
</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
<WelcomeHeader />
{/* Show command history if authenticated */}
{isAuthenticated && commandHistory.length > 0 && (
<Box flexDirection="column" paddingX={2} marginBottom={1}>
{commandHistory.slice(-5).map((cmd, idx) => (
<Box key={idx}>
<Text dimColor> {cmd}</Text>
</Box>
))}
</Box>
)}
{isAuthenticated ? (
<CommandInput onSubmit={handleCommand} />
) : (
<AuthPrompt onAuth={handleAuth} />
)}
</Box>
);
}

View File

@ -0,0 +1,42 @@
import React, { useEffect } from 'react';
import { Text, Box, useApp } from 'ink';
import { AnimatedLogo } from '../components/animated-logo.js';
export function Welcome() {
const { exit } = useApp();
// Auto-exit after a few seconds
useEffect(() => {
const timer = setTimeout(() => {
exit();
}, 5000);
return () => clearTimeout(timer);
}, [exit]);
return (
<Box paddingY={2} paddingX={2}>
<Box marginRight={4}>
<AnimatedLogo color="#7C3AED" />
</Box>
<Box flexDirection="column" justifyContent="center">
<Text bold>Welcome to Buster</Text>
<Box marginTop={1}>
<Text dimColor>Type / to use slash commands</Text>
</Box>
<Box>
<Text dimColor>Type @ to mention files</Text>
</Box>
<Box>
<Text dimColor>Ctrl-C to exit</Text>
</Box>
<Box marginTop={2}>
<Text dimColor>/help for more</Text>
</Box>
<Box marginTop={2}>
<Text color="#7C3AED">"Run `buster` and fix all the errors"</Text>
</Box>
</Box>
</Box>
);
}

View File

@ -0,0 +1,91 @@
import React, { useState, useEffect } from 'react';
import { Text, Box } from 'ink';
// ASCII art for the Buster "b" logo - compact version
const BUSTER_LOGO_FRAMES = [
// Frame 1 - dimmer
`
.:::::.
.=++++++=.
:+++++++++=:
.+++=:.:=+++:.
:+++. .+++:
:+++=====:.
:+++. .:==:
:+++: :+++:
.+++=:.:=+++=.
:+++++++++=:
.=++++++=.
.:::::.
`,
// Frame 2 - medium brightness
`
.=====.
.=#####+=.
:#########+:
.###=:.:=###:.
:###. .###:
:###=====:.
:###. .:==:
:###: :###:
.###=:.:=###+.
:#########+:
.=#####+=.
.=====.
`,
// Frame 3 - brightest
`
.=@@@=.
.=@@@@@+=.
:@@@@@@@@@+:
.@@@=:.:=@@@:.
:@@@. .@@@:
:@@@=====:.
:@@@. .:==:
:@@@: :@@@:
.@@@=:.:=@@@+.
:@@@@@@@@@+:
.=@@@@@+=.
.=@@@=.
`
];
interface AnimatedLogoProps {
color?: string;
}
export function AnimatedLogo({ color = '#7C3AED' }: AnimatedLogoProps) {
const [frame, setFrame] = useState(0);
const [opacity, setOpacity] = useState(0);
const [growing, setGrowing] = useState(true);
useEffect(() => {
const interval = setInterval(() => {
if (growing) {
if (opacity < 2) {
setOpacity(opacity + 1);
} else {
setGrowing(false);
}
} else {
if (opacity > 0) {
setOpacity(opacity - 1);
} else {
setGrowing(true);
}
}
setFrame((prev) => (prev + 1) % 3);
}, 200);
return () => clearInterval(interval);
}, [opacity, growing]);
// Select the appropriate frame based on opacity
const currentFrame = BUSTER_LOGO_FRAMES[opacity];
return (
<Box flexDirection="column" alignItems="center">
<Text color={color}>{currentFrame}</Text>
</Box>
);
}

View File

@ -0,0 +1 @@
export { AnimatedLogo } from './animated-logo.js';

View File

@ -2,14 +2,34 @@
import React from 'react';
import { render } from 'ink';
import { program } from 'commander';
import { Main } from './commands/main.js';
import { Auth } from './commands/auth.js';
import { HelloCommand } from './commands/hello.js';
import { InteractiveCommand } from './commands/interactive.js';
// CLI metadata
program
.name('buster')
.description('Buster CLI - TypeScript version')
.version('0.1.0');
.description('Buster CLI - AI-powered data analytics platform')
.version('0.1.0')
.action(() => {
// Default action when no subcommand is provided
render(<Main />);
});
// Auth command - authentication management
program
.command('auth')
.description('Authenticate with Buster API')
.option('--api-key <key>', 'Your Buster API key')
.option('--host <url>', 'Custom API host URL')
.option('--local', 'Use local development server (http://localhost:3001)')
.option('--cloud', 'Use cloud instance (https://api2.buster.so)')
.option('--clear', 'Clear saved credentials')
.option('--no-save', "Don't save credentials to disk")
.action(async (options) => {
render(<Auth {...options} />);
});
// Hello command - basic example
program
@ -18,7 +38,7 @@ program
.argument('[name]', 'Name to greet', 'World')
.option('-u, --uppercase', 'Output in uppercase')
.action(async (name: string, options: { uppercase?: boolean }) => {
render(<HelloCommand name={name} uppercase={options.uppercase} />);
render(<HelloCommand name={name} uppercase={options.uppercase || false} />);
});
// Interactive command - demonstrates Ink's capabilities
@ -30,9 +50,4 @@ program
});
// Parse command line arguments
program.parse(process.argv);
// Show help if no command provided
if (!process.argv.slice(2).length) {
program.outputHelp();
}
program.parse(process.argv);

View File

@ -0,0 +1,124 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { z } from 'zod';
// Credentials schema
const credentialsSchema = z.object({
apiKey: z.string().min(1),
apiUrl: z.string().url(),
});
export type Credentials = z.infer<typeof credentialsSchema>;
// Default configuration
const DEFAULT_API_URL = 'https://api2.buster.so';
const CREDENTIALS_DIR = join(homedir(), '.buster');
const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
/**
* Ensures the credentials directory exists
*/
async function ensureCredentialsDir(): Promise<void> {
try {
await mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
} catch (error) {
console.error('Failed to create credentials directory:', error);
throw new Error('Unable to create credentials directory');
}
}
/**
* Saves credentials to the local filesystem
* @param credentials - The credentials to save
*/
export async function saveCredentials(credentials: Credentials): Promise<void> {
try {
// Validate credentials
const validated = credentialsSchema.parse(credentials);
// Ensure directory exists
await ensureCredentialsDir();
// Write credentials with restricted permissions
await writeFile(
CREDENTIALS_FILE,
JSON.stringify(validated, null, 2),
{ mode: 0o600 }
);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid credentials: ${error.errors.map(e => e.message).join(', ')}`);
}
console.error('Failed to save credentials:', error);
throw new Error('Unable to save credentials');
}
}
/**
* Loads credentials from the local filesystem
* @returns The saved credentials or null if not found
*/
export async function loadCredentials(): Promise<Credentials | null> {
try {
const data = await readFile(CREDENTIALS_FILE, 'utf-8');
const parsed = JSON.parse(data);
return credentialsSchema.parse(parsed);
} catch (error) {
// File doesn't exist or is invalid
return null;
}
}
/**
* Deletes saved credentials
*/
export async function deleteCredentials(): Promise<void> {
try {
await unlink(CREDENTIALS_FILE);
} catch (error) {
// Ignore if file doesn't exist
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to delete credentials:', error);
throw new Error('Unable to delete credentials');
}
}
}
/**
* Gets credentials from environment variables or saved file
* @returns Credentials with environment variables taking precedence
*/
export async function getCredentials(): Promise<Credentials | null> {
const envApiKey = process.env.BUSTER_API_KEY;
const envApiUrl = process.env.BUSTER_HOST || process.env.BUSTER_API_URL;
// If we have env vars, use them (they take precedence)
if (envApiKey) {
return {
apiKey: envApiKey,
apiUrl: envApiUrl || DEFAULT_API_URL,
};
}
// Otherwise, try to load from file
const saved = await loadCredentials();
if (saved) {
// Apply env overrides if present
return {
apiKey: saved.apiKey,
apiUrl: envApiUrl || saved.apiUrl,
};
}
return null;
}
/**
* Checks if the user has valid credentials configured
* @returns true if credentials are available, false otherwise
*/
export async function hasCredentials(): Promise<boolean> {
const creds = await getCredentials();
return creds !== null && creds.apiKey.length > 0;
}

View File

@ -0,0 +1,22 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { validateApiKeyRequestSchema } from '@buster/server-shared';
import { validateApiKeyHandler } from './validate-api-key';
const app = new Hono();
/**
* POST /api/v2/auth/validate-api-key
* Validates an API key
*/
app.post(
'/validate-api-key',
zValidator('json', validateApiKeyRequestSchema),
async (c) => {
const request = c.req.valid('json');
const response = await validateApiKeyHandler(request);
return c.json(response);
}
);
export default app;

View File

@ -0,0 +1,44 @@
import type { ValidateApiKeyRequest, ValidateApiKeyResponse } from '@buster/server-shared';
import { validateApiKey, getApiKeyDetails } from '@buster/database';
/**
* Handler for validating an API key
* @param request - The validation request containing the API key
* @returns Promise<ValidateApiKeyResponse> - Response with validation status and details
*/
export async function validateApiKeyHandler(
request: ValidateApiKeyRequest
): Promise<ValidateApiKeyResponse> {
try {
// First check if the API key is valid
const isValid = await validateApiKey(request.apiKey);
if (!isValid) {
return {
valid: false,
};
}
// If valid, get the details
const details = await getApiKeyDetails(request.apiKey);
if (!details) {
// This shouldn't happen if validateApiKey returned true, but handle it
return {
valid: false,
};
}
return {
valid: true,
organizationId: details.organizationId,
ownerId: details.ownerId,
};
} catch (error) {
console.error('Error validating API key:', error);
// Don't expose internal errors to the client
return {
valid: false,
};
}
}

View File

@ -1,6 +1,7 @@
import { Hono } from 'hono';
import healthcheckRoutes from '../healthcheck';
import authRoutes from './auth';
import chatsRoutes from './chats';
import dictionariesRoutes from './dictionaries';
import electricShapeRoutes from './electric-shape';
@ -16,6 +17,7 @@ import titleRoutes from './title';
import userRoutes from './users';
const app = new Hono()
.route('/auth', authRoutes)
.route('/users', userRoutes)
.route('/electric-shape', electricShapeRoutes)
.route('/healthcheck', healthcheckRoutes)

View File

@ -10,6 +10,8 @@
"ci:check": "pnpm run check && pnpm run typecheck",
"cli": "cd apps/cli && bun src/index.tsx",
"cli:dev": "cd apps/cli && bun run --watch src/index.tsx",
"cli:build": "pnpm --filter @buster-app/cli build",
"cli:prod": "cd apps/cli && bun run dist/index.js",
"db:check": "pnpm --filter @buster/database run db:check",
"db:generate": "pnpm --filter @buster/database run db:generate",
"db:generate:custom": "pnpm --filter @buster/database run db:generate:custom",

View File

@ -0,0 +1 @@
export { validateApiKey, getApiKeyDetails } from './validate-api-key';

View File

@ -0,0 +1,66 @@
import { and, eq, isNull } from 'drizzle-orm';
import { apiKeys } from '../../schema';
import { getDb } from '../../connection';
/**
* Validates an API key by checking if it exists in the database
* and is not soft-deleted
* @param apiKey - The API key string to validate
* @returns Promise<boolean> - true if the API key is valid, false otherwise
*/
export async function validateApiKey(apiKey: string): Promise<boolean> {
const db = getDb();
try {
const result = await db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(
and(
eq(apiKeys.key, apiKey),
isNull(apiKeys.deletedAt)
)
)
.limit(1);
return result.length > 0;
} catch (error) {
console.error('Error validating API key:', error);
return false;
}
}
/**
* Gets the API key details if it exists and is not soft-deleted
* @param apiKey - The API key string to validate
* @returns Promise<{id: string; organizationId: string; ownerId: string} | null>
*/
export async function getApiKeyDetails(apiKey: string): Promise<{
id: string;
organizationId: string;
ownerId: string;
} | null> {
const db = getDb();
try {
const result = await db
.select({
id: apiKeys.id,
organizationId: apiKeys.organizationId,
ownerId: apiKeys.ownerId,
})
.from(apiKeys)
.where(
and(
eq(apiKeys.key, apiKey),
isNull(apiKeys.deletedAt)
)
)
.limit(1);
return result[0] || null;
} catch (error) {
console.error('Error getting API key details:', error);
return null;
}
}

View File

@ -15,3 +15,4 @@ export * from './s3-integrations';
export * from './vault';
export * from './cascading-permissions';
export * from './github-integrations';
export * from './api-keys';

View File

@ -0,0 +1 @@
export { validateApiKey, isApiKeyValid } from './validate-api-key';

View File

@ -0,0 +1,41 @@
import type { ValidateApiKeyRequest, ValidateApiKeyResponse } from '@buster/server-shared';
import type { SDKConfig } from '../config';
import { post } from '../http';
/**
* Validates an API key by calling the server endpoint
* @param config - SDK configuration
* @param apiKey - The API key to validate (optional, uses config.apiKey if not provided)
* @returns Promise<ValidateApiKeyResponse> - Validation result with details
*/
export async function validateApiKey(
config: SDKConfig,
apiKey?: string
): Promise<ValidateApiKeyResponse> {
const request: ValidateApiKeyRequest = {
apiKey: apiKey || config.apiKey,
};
console.info(`Validating API key with endpoint: ${config.apiUrl}/api/v2/auth/validate-api-key`);
return post<ValidateApiKeyResponse>(config, '/api/v2/auth/validate-api-key', request);
}
/**
* Simple validation check that returns just a boolean
* @param config - SDK configuration
* @param apiKey - The API key to validate (optional, uses config.apiKey if not provided)
* @returns Promise<boolean> - true if valid, false otherwise
*/
export async function isApiKeyValid(
config: SDKConfig,
apiKey?: string
): Promise<boolean> {
try {
const response = await validateApiKey(config, apiKey);
return response.valid;
} catch (error) {
console.error('API key validation error:', error);
return false;
}
}

View File

@ -1,10 +1,16 @@
import { type SDKConfig, SDKConfigSchema } from '../config';
import { get } from '../http';
import { validateApiKey, isApiKeyValid } from '../auth';
import type { ValidateApiKeyResponse } from '@buster/server-shared';
// SDK instance interface
export interface BusterSDK {
readonly config: SDKConfig;
healthcheck: () => Promise<{ status: string; [key: string]: unknown }>;
auth: {
validateApiKey: (apiKey?: string) => Promise<ValidateApiKeyResponse>;
isApiKeyValid: (apiKey?: string) => Promise<boolean>;
};
}
// Create SDK instance
@ -15,5 +21,9 @@ export function createBusterSDK(config: Partial<SDKConfig>): BusterSDK {
return {
config: validatedConfig,
healthcheck: () => get(validatedConfig, '/api/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://api.buster.so'),
apiUrl: z.string().url().default('https://api2.buster.so'),
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

@ -7,3 +7,6 @@ export type { SDKConfig } from './config';
// Error types
export { SDKError, NetworkError } from './errors';
// Auth exports
export { validateApiKey, isApiKeyValid } from './auth';

View File

@ -0,0 +1,17 @@
import { z } from 'zod';
export enum AuthErrorCode {
INVALID_API_KEY = 'INVALID_API_KEY',
MISSING_API_KEY = 'MISSING_API_KEY',
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
VALIDATION_ERROR = 'VALIDATION_ERROR',
INTERNAL_ERROR = 'INTERNAL_ERROR',
}
export const authErrorSchema = z.object({
code: z.nativeEnum(AuthErrorCode),
message: z.string(),
details: z.any().optional(),
});
export type AuthError = z.infer<typeof authErrorSchema>;

View File

@ -0,0 +1,3 @@
export * from './requests';
export * from './responses';
export * from './errors';

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
/**
* Request schema for validating an API key
*/
export const validateApiKeyRequestSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
});
export type ValidateApiKeyRequest = z.infer<typeof validateApiKeyRequestSchema>;

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
/**
* Response schema for API key validation
*/
export const validateApiKeyResponseSchema = z.object({
valid: z.boolean(),
organizationId: z.string().uuid().optional(),
ownerId: z.string().uuid().optional(),
});
export type ValidateApiKeyResponse = z.infer<typeof validateApiKeyResponseSchema>;

View File

@ -5,6 +5,7 @@ export * as AccessControls from './access-controls';
// Export other modules
export * from './assets';
export * from './auth';
export * from './chats';
export * from './dashboards';
export * from './dictionary';

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ packages:
- "apps/electric-server"
- "apps/trigger"
- "apps/api"
- "apps/cli"
catalog:
"@supabase/supabase-js": "^2.50.0"