-
Notifications
You must be signed in to change notification settings - Fork 90
feat: Implement welcome onboarding flow for first-time users #1001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4d3b723
da1337a
884a6d8
d966a2a
73961fb
da955ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,82 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||
| * @license | ||||||||||||||||||||||||||||||||||||||||||||||||
| * Copyright 2025 Vybestack LLC | ||||||||||||||||||||||||||||||||||||||||||||||||
| * SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import * as fs from 'node:fs'; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import * as path from 'node:path'; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { USER_SETTINGS_DIR } from './paths.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export const WELCOME_CONFIG_FILENAME = 'welcomeConfig.json'; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function getWelcomeConfigPath(): string { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (process.env['LLXPRT_CODE_WELCOME_CONFIG_PATH']) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return process.env['LLXPRT_CODE_WELCOME_CONFIG_PATH']; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| return path.join(USER_SETTINGS_DIR, WELCOME_CONFIG_FILENAME); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export interface WelcomeConfig { | ||||||||||||||||||||||||||||||||||||||||||||||||
| welcomeCompleted: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||
| completedAt?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||
| skipped?: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| let cachedConfig: WelcomeConfig | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function resetWelcomeConfigForTesting(): void { | ||||||||||||||||||||||||||||||||||||||||||||||||
| cachedConfig = undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function loadWelcomeConfig(): WelcomeConfig { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (cachedConfig) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return cachedConfig; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const configPath = getWelcomeConfigPath(); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (fs.existsSync(configPath)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const content = fs.readFileSync(configPath, 'utf-8'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| cachedConfig = JSON.parse(content) as WelcomeConfig; | ||||||||||||||||||||||||||||||||||||||||||||||||
| return cachedConfig; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (_error) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| // If parsing fails, return default | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| cachedConfig = { welcomeCompleted: false }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| return cachedConfig; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function saveWelcomeConfig(config: WelcomeConfig): void { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const configPath = getWelcomeConfigPath(); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const dirPath = path.dirname(configPath); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!fs.existsSync(dirPath)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| fs.mkdirSync(dirPath, { recursive: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { | ||||||||||||||||||||||||||||||||||||||||||||||||
| encoding: 'utf-8', | ||||||||||||||||||||||||||||||||||||||||||||||||
| mode: 0o600, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| cachedConfig = config; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.error('Error saving welcome config:', error); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace The coding guidelines explicitly prohibit π Proposed fix+import { Logger } from '@vybestack/llxprt-code-core';
+
+const logger = new Logger('welcomeConfig');
+
export function saveWelcomeConfig(config: WelcomeConfig): void {
const configPath = getWelcomeConfigPath();
try {
const dirPath = path.dirname(configPath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
cachedConfig = config;
} catch (error) {
- console.error('Error saving welcome config:', error);
+ logger.error('Error saving welcome config:', error);
}
}As per coding guidelines. π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function markWelcomeCompleted(skipped: boolean = false): void { | ||||||||||||||||||||||||||||||||||||||||||||||||
| saveWelcomeConfig({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| welcomeCompleted: true, | ||||||||||||||||||||||||||||||||||||||||||||||||
| completedAt: new Date().toISOString(), | ||||||||||||||||||||||||||||||||||||||||||||||||
| skipped, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function isWelcomeCompleted(): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return loadWelcomeConfig().welcomeCompleted; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Vybestack LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import React, { useMemo, useCallback } from 'react'; | ||
| import { Box, Text } from 'ink'; | ||
| import { Colors } from '../../colors.js'; | ||
| import { | ||
| RadioButtonSelect, | ||
| type RadioSelectItem, | ||
| } from '../shared/RadioButtonSelect.js'; | ||
|
|
||
| // Providers that support OAuth | ||
| const OAUTH_PROVIDERS = new Set(['anthropic', 'openai', 'gemini', 'qwen']); | ||
|
|
||
| const API_KEY_URLS: Record<string, string> = { | ||
| anthropic: 'console.anthropic.com/settings/keys', | ||
| openai: 'platform.openai.com/api-keys', | ||
| gemini: 'aistudio.google.com/app/apikey', | ||
| deepseek: 'platform.deepseek.com/api_keys', | ||
| qwen: 'dashscope.console.aliyun.com/apiKey', | ||
| }; | ||
|
|
||
| type AuthMethod = 'oauth' | 'api_key' | 'back'; | ||
|
|
||
| interface AuthMethodStepProps { | ||
| provider: string; | ||
| onSelect: (method: 'oauth' | 'api_key') => void; | ||
| onBack: () => void; | ||
| error?: string; | ||
| isFocused?: boolean; | ||
| } | ||
|
|
||
| export const AuthMethodStep: React.FC<AuthMethodStepProps> = ({ | ||
| provider, | ||
| onSelect, | ||
| onBack, | ||
| error, | ||
| isFocused = true, | ||
| }) => { | ||
| const supportsOAuth = OAUTH_PROVIDERS.has(provider); | ||
| const apiKeyUrl = API_KEY_URLS[provider]; | ||
|
|
||
| const options: Array<RadioSelectItem<AuthMethod>> = useMemo(() => { | ||
| const opts: Array<RadioSelectItem<AuthMethod>> = []; | ||
|
|
||
| if (supportsOAuth) { | ||
| opts.push({ | ||
| label: 'OAuth (Recommended - secure & easy)', | ||
| value: 'oauth', | ||
| key: 'oauth', | ||
| }); | ||
| } | ||
|
|
||
| opts.push({ | ||
| label: 'API Key', | ||
| value: 'api_key', | ||
| key: 'api_key', | ||
| }); | ||
|
|
||
| opts.push({ | ||
| label: 'β Back to provider selection', | ||
| value: 'back', | ||
| key: 'back', | ||
| }); | ||
|
|
||
| return opts; | ||
| }, [supportsOAuth]); | ||
|
|
||
| const handleSelect = useCallback( | ||
| (value: AuthMethod) => { | ||
| if (value === 'back') { | ||
| onBack(); | ||
| } else { | ||
| onSelect(value); | ||
| } | ||
| }, | ||
| [onBack, onSelect], | ||
| ); | ||
|
|
||
| const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1); | ||
|
|
||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Box flexDirection="column" marginBottom={1}> | ||
| <Text bold color={Colors.AccentCyan}> | ||
| Step 3 of 5: Choose Authentication Method | ||
| </Text> | ||
| <Text> </Text> | ||
| <Text>How would you like to authenticate with {providerDisplay}?</Text> | ||
| </Box> | ||
|
|
||
| {error && ( | ||
| <Box marginBottom={1}> | ||
| <Text color={Colors.AccentRed}>{error}</Text> | ||
| </Box> | ||
| )} | ||
|
|
||
| <RadioButtonSelect | ||
| items={options} | ||
| onSelect={handleSelect} | ||
| isFocused={isFocused} | ||
| /> | ||
|
|
||
| {apiKeyUrl && ( | ||
| <Box marginTop={1}> | ||
| <Text color={Colors.Gray}>Get API key at: {apiKeyUrl}</Text> | ||
| </Box> | ||
| )} | ||
|
|
||
| <Box marginTop={1}> | ||
| <Text color={Colors.Gray}> | ||
| Use ββ to navigate, Enter to select, Esc to skip | ||
| </Text> | ||
| </Box> | ||
| </Box> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add runtime validation for parsed config structure.
The type assertion
as WelcomeConfigat Line 42 assumes the JSON structure is valid without verification. If the file is corrupted or manually edited, this could lead to runtime errors elsewhere.π Proposed fix with validation
export function loadWelcomeConfig(): WelcomeConfig { if (cachedConfig) { return cachedConfig; } const configPath = getWelcomeConfigPath(); try { if (fs.existsSync(configPath)) { const content = fs.readFileSync(configPath, 'utf-8'); - cachedConfig = JSON.parse(content) as WelcomeConfig; + const parsed = JSON.parse(content); + + // Validate structure + if ( + typeof parsed === 'object' && + parsed !== null && + typeof parsed.welcomeCompleted === 'boolean' + ) { + cachedConfig = parsed as WelcomeConfig; + } else { + // Invalid structure, fall through to default + throw new Error('Invalid config structure'); + } return cachedConfig; } } catch (_error) { // If parsing fails, return default } cachedConfig = { welcomeCompleted: false }; return cachedConfig; }π€ Prompt for AI Agents