diff --git a/.gitignore b/.gitignore index 1213b10..8a1acbe 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ chrome-debug.log # Firebase credentials ios/GoogleService-Info.plist android/app/google-services.json + +# Clix CLI local config +.clix/ diff --git a/src/cli.tsx b/src/cli.tsx index cf0f292..656f9fa 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -8,10 +8,12 @@ import { runIosSetupCommand } from './commands/ios-setup/index'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; import { resumeCommand } from './commands/resume'; +import { setupCommand } from './commands/setup'; import { skillCommand } from './commands/skill/index'; import { uninstallCommand } from './commands/uninstall'; import { updateCommand } from './commands/update'; import { whoamiCommand } from './commands/whoami'; +import { checkFirstRun, shouldRunSetup } from './lib/services/first-run-service'; import { getValidMCPAgents, isValidMCPAgent, @@ -132,6 +134,11 @@ async function main() { const skillTypes = getAvailableSkillTypes(); try { + // Check if first-run setup is needed + if (await shouldRunSetup(command)) { + await setupCommand(); + } + switch (command) { case 'help': cli.showHelp(); @@ -200,6 +207,16 @@ async function main() { await firebaseCommand(); break; + case 'setup': { + const status = await checkFirstRun(); + if (status.needsSetup) { + await setupCommand(); + } else { + console.log('Project already configured.'); + } + break; + } + case 'ios-setup': case 'capabilities': case 'ios-capabilities': { diff --git a/src/commands/setup.tsx b/src/commands/setup.tsx new file mode 100644 index 0000000..40c1a26 --- /dev/null +++ b/src/commands/setup.tsx @@ -0,0 +1,31 @@ +import { SetupUI } from '@/ui/SetupUI'; +import { safeRender } from '@/ui/utils/safeRender'; + +interface SetupCommandOptions { + /** Project path (defaults to cwd) */ + projectPath?: string; +} + +/** + * Run the project setup command. + * Sets up .clix/config.jsonc with org, project, and member information. + */ +export async function setupCommand(options?: SetupCommandOptions): Promise { + return new Promise((resolve, reject) => { + const { waitUntilExit } = safeRender( + { + resolve(); + }} + onError={(error) => { + reject(error); + }} + />, + ); + + waitUntilExit().then(() => { + resolve(); + }); + }); +} diff --git a/src/lib/__tests__/config.test.ts b/src/lib/__tests__/config.test.ts index c5d4466..c948125 100644 --- a/src/lib/__tests__/config.test.ts +++ b/src/lib/__tests__/config.test.ts @@ -29,7 +29,7 @@ describe('ConfigManager', () => { const config = await manager.load(); expect(config.selectedAgent).toBe(''); - expect(config.version).toBe(4); + expect(config.version).toBe(5); expect(config.ui).toBeDefined(); expect(config.ui.streaming).toBe(true); }); @@ -69,7 +69,7 @@ describe('ConfigManager', () => { const config = await manager.load(); expect(config.selectedAgent).toBe('claude'); - expect(config.version).toBe(4); + expect(config.version).toBe(5); }); test('should create config directory if it does not exist', async () => { diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 996b69f..341415a 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -28,6 +28,7 @@ export interface Project { id: string; name: string; organization_id: string; + public_key?: string; created_at?: string; updated_at?: string; } diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 720f6d8..b72f115 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -5,6 +5,27 @@ */ export { ConfigManager, getConfigManager, resetConfigManager } from './manager'; +// Project-local configuration +export { getProjectConfigManager, ProjectConfigManager } from './project-config-manager'; +export { + CURRENT_PROJECT_CONFIG_VERSION, + PROJECT_CONFIG_DIR, + PROJECT_CONFIG_FILENAME, + type ProjectConfig, + ProjectConfigSchema, + type ProjectFramework, + type ProjectInfo, + ProjectInfoSchema, + type ProjectMember, + ProjectMemberSchema, + type ProjectOrganization, + ProjectOrganizationSchema, + type ProjectTargetPlatform, + type ProjectType, + ProjectTypeSchema, + safeValidateProjectConfig, + validateProjectConfig, +} from './project-config-schema'; export { type AgentConfig, AgentConfigSchema, @@ -17,14 +38,10 @@ export { type DeepPartial, type ExperimentalConfig, ExperimentalSchema, - type LinkedProject, - LinkedProjectSchema, PartialConfigSchema, safeValidateConfig, type UIConfig, UIConfigSchema, validateConfig, validatePartialConfig, - type WorkspaceMappings, - WorkspaceMappingsSchema, } from './schema'; diff --git a/src/lib/config/manager.ts b/src/lib/config/manager.ts index fddcd01..926d95a 100644 --- a/src/lib/config/manager.ts +++ b/src/lib/config/manager.ts @@ -18,7 +18,7 @@ import { * Current configuration schema version. * Increment when making breaking changes to config structure. */ -const CURRENT_VERSION = 4; +const CURRENT_VERSION = 5; /** * Migration function type. @@ -75,17 +75,24 @@ const MIGRATIONS: Record = { return migrated; }, - // Migration from v3 to v4: Add workspace mappings + // Migration from v3 to v4: Add workspace mappings (legacy) 4: (config: RawConfig): RawConfig => { const migrated: RawConfig = { ...config, version: 4 }; - // Initialize empty workspaces if not present + // Initialize empty workspaces if not present (legacy) if (!migrated.workspaces) { migrated.workspaces = {}; } return migrated; }, + + // Migration from v4 to v5: Remove workspace mappings (moved to project-local .clix/config.jsonc) + 5: (config: RawConfig): RawConfig => { + // Destructure to exclude workspaces field + const { workspaces: _removed, ...rest } = config; + return { ...rest, version: 5 }; + }, }; /** @@ -223,7 +230,6 @@ export class ConfigManager { ...config.update, }, agents: config.agents ?? {}, - workspaces: config.workspaces ?? {}, }; } diff --git a/src/lib/config/project-config-manager.ts b/src/lib/config/project-config-manager.ts new file mode 100644 index 0000000..4273c46 --- /dev/null +++ b/src/lib/config/project-config-manager.ts @@ -0,0 +1,358 @@ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ConfigError, ERROR_CODES } from '../errors/types'; +import { + PROJECT_CONFIG_DIR, + PROJECT_CONFIG_FILENAME, + type ProjectConfig, + safeValidateProjectConfig, +} from './project-config-schema'; + +/** + * Comment header for the project config file. + */ +const CONFIG_HEADER = `// Clix CLI 프로젝트 설정 +// 자동 생성됨 - 수동 수정 시 덮어쓰기될 수 있음 +`; + +/** + * Gitignore patterns to check for .clix directory. + */ +const GITIGNORE_PATTERNS = ['.clix', '.clix/', '/.clix', '/.clix/']; + +/** + * Gitignore entry to add. + */ +const GITIGNORE_ENTRY = '\n# Clix CLI local config\n.clix/\n'; + +/** + * ProjectConfigManager handles loading and saving project-local configuration. + * Configuration is stored in .clix/config.jsonc in the project root. + * + * @example + * ```typescript + * const manager = new ProjectConfigManager('/path/to/project'); + * const config = await manager.load(); + * if (config) { + * console.log(config.project.name); + * } + * ``` + */ +export class ProjectConfigManager { + private projectPath: string; + private configDirPath: string; + private configFilePath: string; + private cachedConfig: ProjectConfig | null = null; + + constructor(projectPath?: string) { + this.projectPath = projectPath ?? process.cwd(); + this.configDirPath = join(this.projectPath, PROJECT_CONFIG_DIR); + this.configFilePath = join(this.configDirPath, PROJECT_CONFIG_FILENAME); + } + + /** + * Get the project root path. + */ + get projectRoot(): string { + return this.projectPath; + } + + /** + * Get the configuration directory path (.clix). + */ + get configDir(): string { + return this.configDirPath; + } + + /** + * Get the configuration file path (.clix/config.jsonc). + */ + get configPath(): string { + return this.configFilePath; + } + + /** + * Check if project config exists. + * + * @returns True if config file exists + */ + async exists(): Promise { + try { + await stat(this.configFilePath); + return true; + } catch { + return false; + } + } + + /** + * Ensure the .clix directory exists. + */ + private async ensureConfigDir(): Promise { + try { + await stat(this.configDirPath); + } catch { + await mkdir(this.configDirPath, { recursive: true, mode: 0o755 }); + } + } + + /** + * Load project configuration from disk. + * Returns null if config doesn't exist. + * + * @returns ProjectConfig or null if not found + */ + async load(): Promise { + // Return cached config if available + if (this.cachedConfig !== null) { + return this.cachedConfig; + } + + try { + const content = await readFile(this.configFilePath, 'utf-8'); + + // Strip JSONC comments for parsing + const jsonContent = stripJsonComments(content); + const rawConfig = JSON.parse(jsonContent); + + // Validate with Zod schema + const validatedConfig = safeValidateProjectConfig(rawConfig); + + if (!validatedConfig) { + throw new ConfigError( + 'Invalid project configuration', + ERROR_CODES.PROJECT_CONFIG_INVALID, + this.configFilePath, + ); + } + + this.cachedConfig = validatedConfig; + return validatedConfig; + } catch (error) { + // File doesn't exist - return null + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return null; + } + + // Re-throw config errors + if (error instanceof ConfigError) { + throw error; + } + + // JSON parse error + if (error instanceof SyntaxError) { + throw new ConfigError( + 'Invalid JSON in project configuration file', + ERROR_CODES.PROJECT_CONFIG_INVALID, + this.configFilePath, + ); + } + + throw error; + } + } + + /** + * Save project configuration to disk. + * Creates the .clix directory if it doesn't exist. + * + * @param config - Configuration to save + */ + async save(config: ProjectConfig): Promise { + await this.ensureConfigDir(); + + // Format as JSONC with header comment + const jsonContent = JSON.stringify(config, null, 2); + const content = `${CONFIG_HEADER}${jsonContent}\n`; + + await writeFile(this.configFilePath, content, 'utf-8'); + this.cachedConfig = config; + } + + /** + * Delete the project configuration. + */ + async delete(): Promise { + const { unlink } = await import('node:fs/promises'); + try { + await unlink(this.configFilePath); + this.cachedConfig = null; + } catch { + // File doesn't exist, ignore + } + } + + /** + * Ensure .clix is in .gitignore. + * Adds entry if not already present. + * + * @returns True if gitignore was modified + */ + async ensureGitignore(): Promise { + const gitignorePath = join(this.projectPath, '.gitignore'); + + try { + let content = ''; + + // Try to read existing .gitignore + try { + content = await readFile(gitignorePath, 'utf-8'); + } catch { + // File doesn't exist, will create new one + } + + // Check if .clix is already ignored + const lines = content.split('\n'); + const hasClixIgnore = lines.some((line) => { + const trimmed = line.trim(); + return GITIGNORE_PATTERNS.includes(trimmed); + }); + + if (hasClixIgnore) { + return false; + } + + // Add .clix to gitignore + const newContent = + content.endsWith('\n') || content === '' + ? `${content}${GITIGNORE_ENTRY}` + : `${content}\n${GITIGNORE_ENTRY}`; + + await writeFile(gitignorePath, newContent, 'utf-8'); + return true; + } catch { + // Non-fatal: log warning but continue + return false; + } + } + + /** + * Clear the cached config (useful for testing). + */ + clearCache(): void { + this.cachedConfig = null; + } +} + +interface CommentParserState { + result: string; + inString: boolean; + inSingleLineComment: boolean; + inMultiLineComment: boolean; + index: number; +} + +/** Check if current position is an unescaped quote */ +function isUnescapedQuote(content: string, index: number): boolean { + if (content[index] !== '"') { + return false; + } + if (index === 0) { + return true; + } + // Count consecutive backslashes before the quote + // Even count (including 0) = unescaped quote, odd count = escaped quote + let backslashCount = 0; + let i = index - 1; + while (i >= 0 && content[i] === '\\') { + backslashCount++; + i--; + } + return backslashCount % 2 === 0; +} + +/** Process a character in string context */ +function processStringChar(state: CommentParserState, char: string): void { + state.result += char; + state.index++; +} + +/** Process comment start/end markers */ +function processCommentMarkers(state: CommentParserState, char: string, nextChar: string): boolean { + // Single-line comment start + if (!state.inMultiLineComment && char === '/' && nextChar === '/') { + state.inSingleLineComment = true; + state.index += 2; + return true; + } + // Single-line comment end + if (state.inSingleLineComment && char === '\n') { + state.inSingleLineComment = false; + state.result += char; + state.index++; + return true; + } + // Multi-line comment start + if (!state.inSingleLineComment && char === '/' && nextChar === '*') { + state.inMultiLineComment = true; + state.index += 2; + return true; + } + // Multi-line comment end + if (state.inMultiLineComment && char === '*' && nextChar === '/') { + state.inMultiLineComment = false; + state.index += 2; + return true; + } + return false; +} + +/** + * Strip JSONC-style comments from a string. + * Handles both single-line (//) and multi-line comments. + * + * @param content - JSONC content + * @returns JSON content without comments + */ +function stripJsonComments(content: string): string { + const state: CommentParserState = { + result: '', + inString: false, + inSingleLineComment: false, + inMultiLineComment: false, + index: 0, + }; + + while (state.index < content.length) { + const char = content[state.index]; + const nextChar = content[state.index + 1]; + + // Handle string boundaries (outside comments) + if (!state.inSingleLineComment && !state.inMultiLineComment) { + if (isUnescapedQuote(content, state.index)) { + state.inString = !state.inString; + processStringChar(state, char); + continue; + } + } + + // Inside a string, just copy + if (state.inString) { + processStringChar(state, char); + continue; + } + + // Handle comment markers + if (processCommentMarkers(state, char, nextChar)) { + continue; + } + + // Copy non-comment characters + if (!state.inSingleLineComment && !state.inMultiLineComment) { + state.result += char; + } + state.index++; + } + + return state.result; +} + +/** + * Get a ProjectConfigManager instance for the current working directory. + * + * @param projectPath - Optional project path (defaults to cwd) + * @returns ProjectConfigManager instance + */ +export function getProjectConfigManager(projectPath?: string): ProjectConfigManager { + return new ProjectConfigManager(projectPath); +} diff --git a/src/lib/config/project-config-schema.ts b/src/lib/config/project-config-schema.ts new file mode 100644 index 0000000..cec3823 --- /dev/null +++ b/src/lib/config/project-config-schema.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; + +/** + * Project framework types. + */ +export type ProjectFramework = 'native' | 'react-native' | 'expo' | 'flutter' | 'unknown'; + +/** + * Target platform types. + */ +export type ProjectTargetPlatform = 'ios' | 'android' | 'ios-android' | 'unknown'; + +/** + * Schema for project type (framework + target platform). + */ +export const ProjectTypeSchema = z.object({ + /** Framework used (native, react-native, expo, flutter, unknown) */ + framework: z.enum(['native', 'react-native', 'expo', 'flutter', 'unknown']), + /** Target platform (ios, android, ios-android, unknown) */ + target: z.enum(['ios', 'android', 'ios-android', 'unknown']), +}); + +export type ProjectType = z.infer; + +/** + * Schema for member information in project config. + */ +export const ProjectMemberSchema = z.object({ + /** Member ID */ + id: z.string(), + /** Member email */ + email: z.string().email(), + /** Member display name */ + name: z.string(), +}); + +/** + * Schema for organization information in project config. + */ +export const ProjectOrganizationSchema = z.object({ + /** Organization ID */ + id: z.string(), + /** Organization name */ + name: z.string(), +}); + +/** + * Schema for project information in project config. + */ +export const ProjectInfoSchema = z.object({ + /** Project ID */ + id: z.string(), + /** Project name */ + name: z.string(), + /** Project public key (for SDK integration) */ + publicKey: z.string().optional(), +}); + +/** + * Main project configuration schema. + * Stored in .clix/config.jsonc in the project root. + */ +export const ProjectConfigSchema = z.object({ + /** Configuration schema version */ + version: z.literal(1), + /** Logged-in member information */ + member: ProjectMemberSchema, + /** Selected organization */ + organization: ProjectOrganizationSchema, + /** Selected project */ + project: ProjectInfoSchema, + /** Detected project type (framework + target platform) */ + projectType: ProjectTypeSchema.optional(), + /** ISO timestamp when this config was created/linked */ + linkedAt: z.string().datetime(), +}); + +/** + * Type definitions derived from schemas. + */ +export type ProjectMember = z.infer; +export type ProjectOrganization = z.infer; +export type ProjectInfo = z.infer; +export type ProjectConfig = z.infer; + +/** + * Current project config version. + */ +export const CURRENT_PROJECT_CONFIG_VERSION = 1; + +/** + * Project config file name. + */ +export const PROJECT_CONFIG_FILENAME = 'config.jsonc'; + +/** + * Project config directory name. + */ +export const PROJECT_CONFIG_DIR = '.clix'; + +/** + * Validate a project config object against the schema. + * + * @param data - Configuration data to validate + * @returns Validated ProjectConfig + * @throws ZodError if validation fails + */ +export function validateProjectConfig(data: unknown): ProjectConfig { + return ProjectConfigSchema.parse(data); +} + +/** + * Safely validate project config without throwing. + * Returns null if validation fails. + * + * @param data - Data to validate + * @returns ProjectConfig if valid, null otherwise + */ +export function safeValidateProjectConfig(data: unknown): ProjectConfig | null { + const result = ProjectConfigSchema.safeParse(data); + return result.success ? result.data : null; +} diff --git a/src/lib/config/schema.ts b/src/lib/config/schema.ts index b04134a..5d3769c 100644 --- a/src/lib/config/schema.ts +++ b/src/lib/config/schema.ts @@ -52,26 +52,6 @@ export const UpdateConfigSchema = z.object({ lastKnownVersion: z.string().optional(), }); -/** - * Schema for linked project configuration. - */ -export const LinkedProjectSchema = z.object({ - /** Project ID */ - projectId: z.string(), - /** Project name */ - projectName: z.string(), - /** Organization ID */ - organizationId: z.string(), - /** Organization name */ - organizationName: z.string(), -}); - -/** - * Schema for workspace-to-project mappings. - * Maps absolute directory paths to linked project info. - */ -export const WorkspaceMappingsSchema = z.record(z.string(), LinkedProjectSchema); - /** * Main configuration schema with versioning for migrations. * Validation-only schema - does not enforce defaults. @@ -91,8 +71,6 @@ export const ConfigSchema = z.object({ experimental: ExperimentalSchema, /** Update configuration */ update: UpdateConfigSchema, - /** Workspace-to-project mappings (directory path -> project info) */ - workspaces: WorkspaceMappingsSchema.optional(), }); /** @@ -107,8 +85,6 @@ export type UIConfig = z.infer; export type AgentConfig = z.infer; export type ExperimentalConfig = z.infer; export type UpdateConfig = z.infer; -export type LinkedProject = z.infer; -export type WorkspaceMappings = z.infer; export type Config = z.infer; /** @@ -156,14 +132,13 @@ export const DEFAULT_UPDATE_CONFIG: UpdateConfig = { * Default configuration values. */ export const DEFAULT_CONFIG: Config = { - version: 4, + version: 5, selectedAgent: '', lastUsedAt: undefined, ui: DEFAULT_UI_CONFIG, agents: {}, experimental: DEFAULT_EXPERIMENTAL_CONFIG, update: DEFAULT_UPDATE_CONFIG, - workspaces: {}, }; /** diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index 6814b83..a601a1c 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -14,6 +14,11 @@ export const ERROR_CODES = { CONFIG_INVALID: 'CONFIG_INVALID', CONFIG_MIGRATION_FAILED: 'CONFIG_MIGRATION_FAILED', + // Project configuration errors + PROJECT_CONFIG_NOT_FOUND: 'PROJECT_CONFIG_NOT_FOUND', + PROJECT_CONFIG_INVALID: 'PROJECT_CONFIG_INVALID', + PROJECT_CONFIG_SAVE_FAILED: 'PROJECT_CONFIG_SAVE_FAILED', + // Network errors NETWORK_ERROR: 'NETWORK_ERROR', NETWORK_TIMEOUT: 'NETWORK_TIMEOUT', @@ -211,3 +216,16 @@ export class FirebaseError extends ClixError { this.file = file; } } + +/** + * Error for project configuration failures. + */ +export class ProjectConfigError extends ClixError { + public readonly projectPath?: string; + + constructor(message: string, code?: ErrorCode, projectPath?: string) { + super(message, code ?? ERROR_CODES.PROJECT_CONFIG_INVALID, true, { projectPath }); + this.name = 'ProjectConfigError'; + this.projectPath = projectPath; + } +} diff --git a/src/lib/services/firebase/detector.ts b/src/lib/services/firebase/detector.ts index c175ff1..8f97c71 100644 --- a/src/lib/services/firebase/detector.ts +++ b/src/lib/services/firebase/detector.ts @@ -9,6 +9,8 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import plist from 'plist'; +import type { ProjectType } from '@/lib/config'; +import { fileExists } from '@/lib/services/project-detector'; import type { ExpectedPaths, FirebaseCredentialFile, @@ -50,132 +52,21 @@ const IOS_SEARCH_PATHS = [ const IGNORE_DIRS = new Set(['node_modules', '.git', 'build', 'dist', '.gradle', 'Pods']); /** - * Check if Flutter project (pubspec.yaml exists). + * Convert ProjectType to Firebase Platform. */ -async function isFlutterProject(projectPath: string): Promise { - try { - await fs.access(path.join(projectPath, 'pubspec.yaml')); - return true; - } catch { - return false; - } -} - -/** - * Check if React Native project. - */ -async function isReactNativeProject(projectPath: string): Promise { - try { - const packageJson = await fs.readFile(path.join(projectPath, 'package.json'), 'utf-8'); - const pkg = JSON.parse(packageJson); - const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }; - return Boolean(deps['react-native'] || deps.expo); - } catch { - return false; +function projectTypeToPlatform(projectType: ProjectType): Platform { + if (projectType.framework === 'flutter') { + return 'flutter'; } -} - -/** - * Check if directory has build.gradle files. - */ -async function hasBuildGradle(projectPath: string, dirName: string): Promise { - const gradlePath = path.join(projectPath, dirName, 'build.gradle'); - const gradleKtsPath = path.join(projectPath, dirName, 'build.gradle.kts'); - try { - await fs.access(gradlePath); - return true; - } catch { - try { - await fs.access(gradleKtsPath); - return true; - } catch { - return false; - } - } -} - -/** - * Check directory entry for iOS indicators. - */ -function isIosIndicator(entryName: string): boolean { - return ( - entryName === 'ios' || entryName.endsWith('.xcodeproj') || entryName.endsWith('.xcworkspace') - ); -} - -/** - * Check file entry for Android indicators. - */ -function isAndroidFile(fileName: string): boolean { - return ( - fileName === 'build.gradle' || - fileName === 'build.gradle.kts' || - fileName === 'AndroidManifest.xml' - ); -} - -/** - * Detect native platforms from directory entries. - */ -async function detectNativePlatforms( - projectPath: string, -): Promise<{ hasIos: boolean; hasAndroid: boolean }> { - const entries = await fs.readdir(projectPath, { withFileTypes: true }); - let hasIos = false; - let hasAndroid = false; - - for (const entry of entries) { - if (entry.isDirectory()) { - if (isIosIndicator(entry.name)) { - hasIos = true; - } - if (entry.name === 'android' || entry.name === 'app') { - if (await hasBuildGradle(projectPath, entry.name)) { - hasAndroid = true; - } - } - } - if (entry.isFile() && isAndroidFile(entry.name)) { - hasAndroid = true; - } + if (projectType.framework === 'react-native' || projectType.framework === 'expo') { + return 'react-native'; } - - return { hasIos, hasAndroid }; -} - -/** - * Detect the project platform based on project files. - */ -export async function detectPlatform(projectPath: string): Promise { - try { - // Check for cross-platform frameworks first - if (await isFlutterProject(projectPath)) { - return 'flutter'; - } - - if (await isReactNativeProject(projectPath)) { - return 'react-native'; - } - - // Check for native platforms - const { hasIos, hasAndroid } = await detectNativePlatforms(projectPath); - - if (hasIos && hasAndroid) { - // For dual-platform native projects without cross-platform framework, - // return 'unknown' to check all common locations - return 'unknown'; - } - if (hasIos) { - return 'ios'; - } - if (hasAndroid) { - return 'android'; - } - - return 'unknown'; - } catch { + if (projectType.framework === 'native') { + if (projectType.target === 'ios') return 'ios'; + if (projectType.target === 'android') return 'android'; return 'unknown'; } + return 'unknown'; } /** @@ -210,18 +101,6 @@ export function getExpectedPaths(platform: Platform): ExpectedPaths { return { android: androidPaths, ios: iosPaths }; } -/** - * Check if a file exists at the given path. - */ -async function fileExists(filePath: string): Promise { - try { - const stat = await fs.stat(filePath); - return stat.isFile(); - } catch { - return false; - } -} - /** * Read and parse a JSON file. */ @@ -637,10 +516,14 @@ function generateIssues( * Detect Firebase configuration in a project. * * @param projectPath - Path to the project root + * @param projectType - Already detected project type * @returns Detection result with credential files and issues */ -export async function detectFirebaseConfig(projectPath: string): Promise { - const platform = await detectPlatform(projectPath); +export async function detectFirebaseConfig( + projectPath: string, + projectType: ProjectType, +): Promise { + const platform = projectTypeToPlatform(projectType); const expectedPaths = getExpectedPaths(platform); const android = await detectAndroidCredential(projectPath, expectedPaths.android); diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index 8a34a4a..c690145 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -8,6 +8,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import type { ProjectType } from '@/lib/config'; import type { AndroidApp, CreateAndroidAppRequest, @@ -16,7 +17,7 @@ import type { IosApp, } from './api'; import { FirebaseApiClient } from './api'; -import { detectPlatform, getExpectedPaths } from './detector'; +import { getExpectedPaths } from './detector'; import { GoogleAuthClient } from './oauth'; import { type Platform, platformNeedsAndroid, platformNeedsIos } from './types'; @@ -223,11 +224,15 @@ export class FirebaseDownloader { /** * Get expected save paths for config files. + * + * @param projectPath - Project root path + * @param projectType - Already detected project type */ - async getExpectedSavePaths( + getExpectedSavePaths( projectPath: string, - ): Promise<{ android: string | null; ios: string | null; platform: Platform }> { - const platform = await detectPlatform(projectPath); + projectType: ProjectType, + ): { android: string | null; ios: string | null; platform: Platform } { + const platform = this.projectTypeToPlatform(projectType); const paths = getExpectedPaths(platform); // For unknown platform, assume both platforms are needed @@ -241,6 +246,24 @@ export class FirebaseDownloader { }; } + /** + * Convert ProjectType to Firebase Platform. + */ + private projectTypeToPlatform(projectType: ProjectType): Platform { + if (projectType.framework === 'flutter') { + return 'flutter'; + } + if (projectType.framework === 'react-native' || projectType.framework === 'expo') { + return 'react-native'; + } + if (projectType.framework === 'native') { + if (projectType.target === 'ios') return 'ios'; + if (projectType.target === 'android') return 'android'; + return 'unknown'; + } + return 'unknown'; + } + /** * Logout (clear stored tokens). */ diff --git a/src/lib/services/firebase/firebase-service.ts b/src/lib/services/firebase/firebase-service.ts index 3e87b3c..ca64b89 100644 --- a/src/lib/services/firebase/firebase-service.ts +++ b/src/lib/services/firebase/firebase-service.ts @@ -6,17 +6,37 @@ * @module services/firebase/firebase-service */ -import { detectFirebaseConfig, detectPlatform, getExpectedPaths } from './detector'; +import type { ProjectType } from '@/lib/config'; +import { detectFirebaseConfig, getExpectedPaths } from './detector'; import type { FirebaseDetectionResult, FirebaseRecommendation, FirebaseStatus, GoogleServiceInfoPlist, GoogleServicesJson, + Platform, } from './types'; import { FIREBASE_HELP_URLS, platformNeedsAndroid, platformNeedsIos } from './types'; import { extractProjectId, extractProjectIdFromPlist, validateProjectIdMatch } from './validator'; +/** + * Convert ProjectType to Firebase Platform. + */ +function projectTypeToPlatform(projectType: ProjectType): Platform { + if (projectType.framework === 'flutter') { + return 'flutter'; + } + if (projectType.framework === 'react-native' || projectType.framework === 'expo') { + return 'react-native'; + } + if (projectType.framework === 'native') { + if (projectType.target === 'ios') return 'ios'; + if (projectType.target === 'android') return 'android'; + return 'unknown'; + } + return 'unknown'; +} + /** * Firebase configuration service. * @@ -24,25 +44,26 @@ import { extractProjectId, extractProjectIdFromPlist, validateProjectIdMatch } f */ export class FirebaseService { private projectPath: string; + private projectType: ProjectType; /** * Create a new FirebaseService instance. * * @param projectPath - Path to the project root directory + * @param projectType - Already detected project type */ - constructor(projectPath: string) { + constructor(projectPath: string, projectType: ProjectType) { this.projectPath = projectPath; + this.projectType = projectType; } /** * Detect Firebase configuration in the project. * - * Always performs fresh detection to ensure current state. - * * @returns Detection result with credential files and issues */ async detect(): Promise { - return await detectFirebaseConfig(this.projectPath); + return await detectFirebaseConfig(this.projectPath, this.projectType); } /** @@ -150,8 +171,8 @@ export class FirebaseService { * @param platform - Target platform * @returns Expected file path */ - async getExpectedPath(platform: 'android' | 'ios'): Promise { - const detectedPlatform = await detectPlatform(this.projectPath); + getExpectedPath(platform: 'android' | 'ios'): string { + const detectedPlatform = projectTypeToPlatform(this.projectType); const paths = getExpectedPaths(detectedPlatform); const platformPaths = platform === 'android' ? paths.android : paths.ios; return ( diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts index 20beb15..b34f530 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -10,7 +10,7 @@ export type { AndroidApp, FirebaseProject, IosApp } from './api'; // Detection and validation -export { detectFirebaseConfig, detectPlatform, getExpectedPaths } from './detector'; +export { detectFirebaseConfig, getExpectedPaths } from './detector'; // Downloader export type { DownloadOptions, DownloadResult } from './downloader'; diff --git a/src/lib/services/first-run-service.ts b/src/lib/services/first-run-service.ts new file mode 100644 index 0000000..3bf4374 --- /dev/null +++ b/src/lib/services/first-run-service.ts @@ -0,0 +1,107 @@ +import { getCredentialsManager } from '../auth/credentials'; +import { getProjectConfigManager } from '../config/project-config-manager'; + +/** + * Result of first-run detection. + */ +export interface FirstRunStatus { + /** Whether setup is needed (no project config) */ + needsSetup: boolean; + /** Whether user is authenticated */ + isAuthenticated: boolean; + /** Whether project config exists */ + hasProjectConfig: boolean; +} + +/** + * Commands that don't require project setup. + * These can run without a .clix/config.jsonc file. + */ +export const SETUP_EXEMPT_COMMANDS = [ + 'help', + 'login', + 'logout', + 'setup', + 'update', + 'upgrade', + 'uninstall', + 'version', + '--help', + '--version', + '-h', + '-v', +] as const; + +/** + * Check if a command is exempt from first-run setup. + * + * @param command - Command name to check + * @returns True if command doesn't require setup + */ +export function isSetupExemptCommand(command: string | undefined): boolean { + if (!command) { + return false; // Default (interactive mode) requires setup + } + return SETUP_EXEMPT_COMMANDS.includes(command as (typeof SETUP_EXEMPT_COMMANDS)[number]); +} + +/** + * Check the first-run status for the current project. + * Determines if setup flow should be triggered. + * + * @param projectPath - Optional project path (defaults to cwd) + * @returns FirstRunStatus indicating what setup is needed + * + * @example + * ```typescript + * const status = await checkFirstRun(); + * if (status.needsSetup) { + * await runSetupFlow(); + * } + * ``` + */ +export async function checkFirstRun(projectPath?: string): Promise { + const credentialsManager = getCredentialsManager(); + const projectConfigManager = getProjectConfigManager(projectPath); + + // Check authentication status + const isAuthenticated = await credentialsManager.isAuthenticated(); + + // Check if project config exists + const hasProjectConfig = await projectConfigManager.exists(); + + // Setup is needed if project config doesn't exist + const needsSetup = !hasProjectConfig; + + return { + needsSetup, + isAuthenticated, + hasProjectConfig, + }; +} + +/** + * Check if the current project needs setup before running a command. + * Combines command exemption check with first-run detection. + * + * @param command - Command being run + * @param projectPath - Optional project path + * @returns True if setup should be run before the command + */ +export async function shouldRunSetup( + command: string | undefined, + projectPath?: string, +): Promise { + // Skip setup if explicitly disabled (for CI/testing) + if (process.env.CLIX_SKIP_SETUP === '1' || process.env.CLIX_SKIP_SETUP === 'true') { + return false; + } + + // Skip setup for exempt commands + if (isSetupExemptCommand(command)) { + return false; + } + + const status = await checkFirstRun(projectPath); + return status.needsSetup; +} diff --git a/src/lib/services/project-detector.ts b/src/lib/services/project-detector.ts new file mode 100644 index 0000000..4a621c4 --- /dev/null +++ b/src/lib/services/project-detector.ts @@ -0,0 +1,225 @@ +/** + * Project type detection. + * + * Detects the framework and target platform of a mobile project. + * + * @module services/project-detector + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { ProjectFramework, ProjectTargetPlatform, ProjectType } from '@/lib/config'; + +/** + * Check if a file exists. + */ +export async function fileExists(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +/** + * Check if a directory exists. + */ +export async function directoryExists(dirPath: string): Promise { + try { + const stat = await fs.stat(dirPath); + return stat.isDirectory(); + } catch { + return false; + } +} + +/** + * Check if Flutter project (pubspec.yaml exists). + */ +export async function isFlutterProject(projectPath: string): Promise { + return fileExists(path.join(projectPath, 'pubspec.yaml')); +} + +/** + * Read and parse package.json dependencies. + */ +async function readPackageDependencies( + projectPath: string, +): Promise | null> { + try { + const packageJsonPath = path.join(projectPath, 'package.json'); + const content = await fs.readFile(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(content); + return { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }; + } catch { + return null; + } +} + +/** + * Check if Expo project (has expo dependency). + */ +async function isExpoProject(projectPath: string): Promise { + const deps = await readPackageDependencies(projectPath); + return deps !== null && 'expo' in deps; +} + +/** + * Check if React Native project (has react-native but not expo). + */ +async function isReactNativeProject(projectPath: string): Promise { + const deps = await readPackageDependencies(projectPath); + if (deps === null) return false; + return 'react-native' in deps && !('expo' in deps); +} + +/** + * Check if directory has iOS project indicators. + */ +async function hasIosProject(projectPath: string): Promise { + // Check for ios/ directory + if (await directoryExists(path.join(projectPath, 'ios'))) { + return true; + } + + // Check for .xcodeproj or .xcworkspace at root level + try { + const entries = await fs.readdir(projectPath, { withFileTypes: true }); + return entries.some( + (entry) => + entry.isDirectory() && + (entry.name.endsWith('.xcodeproj') || entry.name.endsWith('.xcworkspace')), + ); + } catch { + return false; + } +} + +/** + * Check if directory has Android project indicators. + */ +async function hasAndroidProject(projectPath: string): Promise { + // Check for android/ directory with build.gradle + const androidDir = path.join(projectPath, 'android'); + if (await directoryExists(androidDir)) { + const hasGradle = + (await fileExists(path.join(androidDir, 'build.gradle'))) || + (await fileExists(path.join(androidDir, 'build.gradle.kts'))); + if (hasGradle) return true; + } + + // Check for app/ directory with build.gradle (native Android) + const appDir = path.join(projectPath, 'app'); + if (await directoryExists(appDir)) { + const hasGradle = + (await fileExists(path.join(appDir, 'build.gradle'))) || + (await fileExists(path.join(appDir, 'build.gradle.kts'))); + if (hasGradle) return true; + } + + // Check for build.gradle at root (native Android) + if ( + (await fileExists(path.join(projectPath, 'build.gradle'))) || + (await fileExists(path.join(projectPath, 'build.gradle.kts'))) + ) { + return true; + } + + return false; +} + +/** + * Detect target platform based on project structure. + */ +async function detectTargetPlatform(projectPath: string): Promise { + const hasIos = await hasIosProject(projectPath); + const hasAndroid = await hasAndroidProject(projectPath); + + if (hasIos && hasAndroid) { + return 'ios-android'; + } + if (hasIos) { + return 'ios'; + } + if (hasAndroid) { + return 'android'; + } + + return 'unknown'; +} + +/** + * Detect project framework. + */ +async function detectFramework(projectPath: string): Promise { + // Check cross-platform frameworks first (order matters) + if (await isFlutterProject(projectPath)) { + return 'flutter'; + } + + if (await isExpoProject(projectPath)) { + return 'expo'; + } + + if (await isReactNativeProject(projectPath)) { + return 'react-native'; + } + + // Check for native projects + const hasIos = await hasIosProject(projectPath); + const hasAndroid = await hasAndroidProject(projectPath); + + if (hasIos || hasAndroid) { + return 'native'; + } + + return 'unknown'; +} + +/** + * Detect project type (framework + target platform). + * + * @param projectPath - Path to the project root + * @returns Detected project type (unknown if not detected) + */ +export async function detectProjectType(projectPath: string): Promise { + try { + const framework = await detectFramework(projectPath); + const target = await detectTargetPlatform(projectPath); + return { framework, target }; + } catch { + return { framework: 'unknown', target: 'unknown' }; + } +} + +/** + * Format project type for display. + * + * @param type - Project type object + * @returns Human-readable string (e.g., "expo (iOS/Android)") + */ +export function formatProjectType(type: ProjectType): string { + if (type.framework === 'unknown' && type.target === 'unknown') { + return 'unknown'; + } + + const targetLabel = + type.target === 'ios-android' + ? 'iOS/Android' + : type.target === 'ios' + ? 'iOS' + : type.target === 'android' + ? 'Android' + : 'unknown'; + + if (type.framework === 'native') { + return `${targetLabel} native`; + } + + if (type.framework === 'unknown') { + return `unknown (${targetLabel})`; + } + + return `${type.framework} (${targetLabel})`; +} diff --git a/src/lib/utils/__tests__/path.test.ts b/src/lib/utils/__tests__/path.test.ts index ef13f27..c2a4192 100644 --- a/src/lib/utils/__tests__/path.test.ts +++ b/src/lib/utils/__tests__/path.test.ts @@ -50,4 +50,11 @@ describe('formatPath', () => { const result = formatPath('/'); expect(result).toBe('/'); }); + + test('should not modify paths with similar prefix to home directory', () => { + // e.g., if home is /Users/john, /Users/johnny should NOT become ~ny + const inputPath = `${home}ny/some/project`; + const result = formatPath(inputPath); + expect(result).toBe(`${home}ny/some/project`); + }); }); diff --git a/src/lib/utils/path.ts b/src/lib/utils/path.ts index d645831..0c2c10a 100644 --- a/src/lib/utils/path.ts +++ b/src/lib/utils/path.ts @@ -7,8 +7,13 @@ import { homedir } from 'node:os'; */ export function formatPath(path: string): string { const home = homedir(); - if (path.startsWith(home)) { - return path.replace(home, '~'); + // Exact match: home directory itself + if (path === home) { + return '~'; + } + // Path under home directory (must have separator after home) + if (path.startsWith(`${home}/`)) { + return `~${path.slice(home.length)}`; } return path; } diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index a1f891a..eb01aaa 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -19,6 +19,7 @@ import { } from '@/lib/ios'; import { FirebaseService } from '@/lib/services/firebase'; import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; +import { detectProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { StatusMessage } from '@/ui/components/StatusMessage'; @@ -217,7 +218,8 @@ async function runSetup( // Use Team ID from Xcode project settings (DEVELOPMENT_TEAM) if available result.teamId = project.teamId || null; try { - const firebaseService = new FirebaseService(process.cwd()); + const projectType = await detectProjectType(process.cwd()); + const firebaseService = new FirebaseService(process.cwd(), projectType); const firebaseDetection = await firebaseService.detect(); const iosContent = firebaseDetection.ios?.content as GoogleServiceInfoPlist | undefined; const androidContent = firebaseDetection.android?.content as GoogleServicesJson | undefined; diff --git a/src/ui/LoginUI.tsx b/src/ui/LoginUI.tsx index dea95d8..97ccbb0 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -2,7 +2,7 @@ import { Box, Text, useApp } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { getInternalApiClient, type Organization, type Project } from '@/lib/api'; +import { getInternalApiClient, type Member, type Organization, type Project } from '@/lib/api'; import { type Credentials, createCredentials, @@ -13,7 +13,12 @@ import { PKCEFlowService, type TokenResponse, } from '@/lib/auth'; -import { getConfigManager, type LinkedProject } from '@/lib/config'; +import { + CURRENT_PROJECT_CONFIG_VERSION, + getProjectConfigManager, + type ProjectConfig, +} from '@/lib/config'; +import { detectProjectType, formatProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { ProjectSelector } from '@/ui/components/ProjectSelector'; import { StatusMessage } from '@/ui/components/StatusMessage'; @@ -40,14 +45,19 @@ interface LoginUIProps { onError?: (error: Error) => void; } +/** Fetch current member info */ +async function fetchMember(): Promise { + const apiClient = getInternalApiClient(); + return apiClient.getMe(); +} + /** Fetch user name from API or ID token */ async function fetchUserName( pkceService: PKCEFlowService, tokenResponse?: TokenResponse, ): Promise { try { - const apiClient = getInternalApiClient(); - const member = await apiClient.getMe(); + const member = await fetchMember(); return member.name || member.email; } catch { // Fall back to ID token @@ -100,37 +110,72 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { const [errorMessage, setErrorMessage] = useState(''); const [userName, setUserName] = useState(''); const [organizations, setOrganizations] = useState([]); - const [linkedProject, setLinkedProject] = useState(null); + const [savedConfig, setSavedConfig] = useState(null); const [workspacePath] = useState(() => process.cwd()); const pkceServiceRef = useRef(null); const credentialsRef = useRef(null); + const memberRef = useRef(null); const handleProjectSelect = useCallback( async (project: Project, org: Organization) => { - const linked: LinkedProject = { - projectId: project.id, - projectName: project.name, - organizationId: org.id, - organizationName: org.name, - }; - setLinkedProject(linked); - - // Save to config - const configManager = getConfigManager(); - const config = await configManager.load(); - const workspaces = { ...config.workspaces, [workspacePath]: linked }; - await configManager.save({ workspaces }); - - setPhase('complete'); - setTimeout(() => { - if (onComplete && credentialsRef.current) { - onComplete(credentialsRef.current); - } else { - exit(); + try { + // Fetch member info if not already fetched + let member = memberRef.current; + if (!member) { + member = await fetchMember(); + memberRef.current = member; + } + + // Detect project type + const projectType = await detectProjectType(workspacePath); + + // Create project config + const projectConfig: ProjectConfig = { + version: CURRENT_PROJECT_CONFIG_VERSION, + member: { + id: member.id, + email: member.email, + name: member.name, + }, + organization: { + id: org.id, + name: org.name, + }, + project: { + id: project.id, + name: project.name, + ...(project.public_key && { publicKey: project.public_key }), + }, + projectType, + linkedAt: new Date().toISOString(), + }; + + // Save to .clix/config.jsonc + const projectConfigManager = getProjectConfigManager(workspacePath); + await projectConfigManager.save(projectConfig); + + // Ensure .clix is in .gitignore + await projectConfigManager.ensureGitignore(); + + setSavedConfig(projectConfig); + setPhase('complete'); + setTimeout(() => { + if (onComplete && credentialsRef.current) { + onComplete(credentialsRef.current); + } else { + exit(); + } + }, 1500); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save configuration'; + setErrorMessage(message); + setPhase('error'); + if (onError) { + onError(error instanceof Error ? error : new Error(message)); } - }, 1500); + } }, - [workspacePath, onComplete, exit], + [workspacePath, onComplete, onError, exit], ); const handleProjectSkip = useCallback(() => { @@ -324,15 +369,25 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { Successfully logged in! - {linkedProject && ( + {savedConfig && ( Organization: - {linkedProject.organizationName} + {savedConfig.organization.name} Project: - {linkedProject.projectName} + {savedConfig.project.name} + + {savedConfig.projectType && ( + + Project type: + {formatProjectType(savedConfig.projectType)} + + )} + + Config saved to: + .clix/config.jsonc )} diff --git a/src/ui/SetupUI.tsx b/src/ui/SetupUI.tsx new file mode 100644 index 0000000..5b93e1a --- /dev/null +++ b/src/ui/SetupUI.tsx @@ -0,0 +1,394 @@ +import { Box, Text, useApp } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getInternalApiClient, type Member, type Organization, type Project } from '@/lib/api'; +import { + type Credentials, + createCredentials, + getAuth0Config, + getCredentialsManager, + getIssuerUrl, + openBrowser, + PKCEFlowService, + type TokenResponse, +} from '@/lib/auth'; +import { + CURRENT_PROJECT_CONFIG_VERSION, + getProjectConfigManager, + type ProjectConfig, +} from '@/lib/config'; +import { detectProjectType } from '@/lib/services/project-detector'; +import { Header } from '@/ui/components/Header'; +import { ProjectSelector } from '@/ui/components/ProjectSelector'; +import { StatusMessage } from '@/ui/components/StatusMessage'; + +type SetupPhase = + | 'checking_auth' + | 'starting_auth' + | 'waiting_for_auth' + | 'exchanging_code' + | 'fetching_data' + | 'selecting_project' + | 'saving_config' + | 'complete' + | 'error'; + +interface OrgWithProjects { + org: Organization; + projects: Project[]; +} + +interface SetupUIProps { + /** Called when setup completes successfully */ + onComplete?: () => void; + /** Called on error */ + onError?: (error: Error) => void; + /** Project path (defaults to cwd) */ + projectPath?: string; +} + +/** Fetch current member info */ +async function fetchMember(): Promise { + const apiClient = getInternalApiClient(); + return apiClient.getMe(); +} + +/** Fetch user name from API or ID token */ +async function fetchUserName( + pkceService: PKCEFlowService, + tokenResponse?: TokenResponse, +): Promise { + try { + const member = await fetchMember(); + return member.name || member.email; + } catch { + // Fall back to ID token + if (tokenResponse?.id_token) { + const userInfo = pkceService.parseIdToken(tokenResponse.id_token); + if (userInfo) { + return userInfo.name ?? userInfo.email ?? ''; + } + } + return ''; + } +} + +/** Fetch organizations and their projects */ +async function fetchOrganizationsWithProjects(): Promise { + const orgsWithProjects: OrgWithProjects[] = []; + try { + const apiClient = getInternalApiClient(); + const orgs = await apiClient.listOrganizations(); + for (const org of orgs) { + const projects = await apiClient.listProjects(org.id); + orgsWithProjects.push({ org, projects }); + } + } catch { + // Silently ignore org/project fetch errors + } + return orgsWithProjects; +} + +export const SetupUI: React.FC = ({ onComplete, onError, projectPath }) => { + const { exit } = useApp(); + const [phase, setPhase] = useState('checking_auth'); + const [browserOpened, setBrowserOpened] = useState(false); + const [authUrl, setAuthUrl] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [userName, setUserName] = useState(''); + const [organizations, setOrganizations] = useState([]); + const [savedConfig, setSavedConfig] = useState(null); + const [workspacePath] = useState(() => projectPath ?? process.cwd()); + const pkceServiceRef = useRef(null); + const memberRef = useRef(null); + + const handleProjectSelect = useCallback( + async (project: Project, org: Organization) => { + setPhase('saving_config'); + + try { + // Fetch member info if not already fetched + let member = memberRef.current; + if (!member) { + member = await fetchMember(); + memberRef.current = member; + } + + // Detect project type + const projectType = await detectProjectType(workspacePath); + + // Create project config + const config: ProjectConfig = { + version: CURRENT_PROJECT_CONFIG_VERSION, + member: { + id: member.id, + email: member.email, + name: member.name, + }, + organization: { + id: org.id, + name: org.name, + }, + project: { + id: project.id, + name: project.name, + ...(project.public_key && { publicKey: project.public_key }), + }, + projectType, + linkedAt: new Date().toISOString(), + }; + + // Save to .clix/config.jsonc + const projectConfigManager = getProjectConfigManager(workspacePath); + await projectConfigManager.save(config); + + // Ensure .clix is in .gitignore + await projectConfigManager.ensureGitignore(); + + setSavedConfig(config); + setPhase('complete'); + + setTimeout(() => { + if (onComplete) { + onComplete(); + } else { + exit(); + } + }, 1500); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save configuration'; + setErrorMessage(message); + setPhase('error'); + if (onError) { + onError(error instanceof Error ? error : new Error(message)); + } + } + }, + [workspacePath, onComplete, onError, exit], + ); + + const handleProjectSkip = useCallback(() => { + // Cannot skip project selection in setup - it's required + // This callback is provided but should not be used + }, []); + + useEffect(() => { + const config = getAuth0Config(); + const pkceService = new PKCEFlowService(config); + pkceServiceRef.current = pkceService; + const credentialsManager = getCredentialsManager(); + + const runSetup = async () => { + try { + // Check if already authenticated + setPhase('checking_auth'); + const isAuthenticated = await credentialsManager.isAuthenticated(); + + if (isAuthenticated) { + // Already logged in, fetch data + setPhase('fetching_data'); + const name = await fetchUserName(pkceService); + setUserName(name); + + const member = await fetchMember(); + memberRef.current = member; + + const orgsData = await fetchOrganizationsWithProjects(); + setOrganizations(orgsData); + + // Check if there are projects to select from + const hasProjects = orgsData.some((o) => o.projects.length > 0); + if (hasProjects) { + setPhase('selecting_project'); + } else { + setErrorMessage( + 'No projects found. Please create a project in the Clix console first.', + ); + setPhase('error'); + } + return; + } + + // Not authenticated - start login flow + setPhase('starting_auth'); + const { authUrl: url } = await pkceService.startAuthFlow(); + setAuthUrl(url); + + // Open browser + const opened = await openBrowser(url); + setBrowserOpened(opened); + setPhase('waiting_for_auth'); + + // Wait for callback + const code = await pkceService.waitForCallback(); + + // Exchange code for tokens + setPhase('exchanging_code'); + const tokenResponse = await pkceService.exchangeCodeForTokens(code); + + // Save credentials + const issuer = getIssuerUrl(config); + const credentials: Credentials = createCredentials(tokenResponse, issuer, config.audience); + await credentialsManager.save(credentials); + + // Fetch user data + setPhase('fetching_data'); + const name = await fetchUserName(pkceService, tokenResponse); + setUserName(name); + + const member = await fetchMember(); + memberRef.current = member; + + const orgsData = await fetchOrganizationsWithProjects(); + setOrganizations(orgsData); + + // Check if there are projects to select from + const hasProjects = orgsData.some((o) => o.projects.length > 0); + if (hasProjects) { + setPhase('selecting_project'); + } else { + setErrorMessage('No projects found. Please create a project in the Clix console first.'); + setPhase('error'); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Setup failed'; + setErrorMessage(message); + setPhase('error'); + if (onError) { + onError(error instanceof Error ? error : new Error(message)); + } else { + setTimeout(() => exit(), 2000); + } + } + }; + + runSetup(); + + // Cleanup on unmount + return () => { + pkceServiceRef.current?.abort(); + }; + }, [exit, onError]); + + return ( + +
+ + {phase === 'checking_auth' && ( + + )} + + {phase === 'starting_auth' && ( + + )} + + {phase === 'waiting_for_auth' && ( + + {browserOpened ? ( + + + Browser opened for authentication + + ) : ( + + + Could not open browser automatically. + + Open this URL in your browser: + + + {authUrl} + + + )} + + + + + Waiting for authentication in browser... + + + Press + Ctrl+C + to cancel + + + )} + + {phase === 'exchanging_code' && ( + + )} + + {phase === 'fetching_data' && ( + + )} + + {phase === 'selecting_project' && ( + + + + ✓ + + Authenticated + + {userName && ( + + Welcome, + {userName} + + )} + + Select a project to link to this directory: + + + + )} + + {phase === 'saving_config' && ( + + )} + + {phase === 'complete' && savedConfig && ( + + + + ✓ + + Project setup complete! + + + + Organization: + {savedConfig.organization.name} + + + Project: + {savedConfig.project.name} + + + Config saved to: + .clix/config.jsonc + + + + )} + + {phase === 'error' && ( + + + + Please try again or check + https://console.clix.so + + + )} + + ); +}; diff --git a/src/ui/WhoamiUI.tsx b/src/ui/WhoamiUI.tsx index 497e356..fd763c6 100644 --- a/src/ui/WhoamiUI.tsx +++ b/src/ui/WhoamiUI.tsx @@ -3,7 +3,7 @@ import type React from 'react'; import { useEffect, useState } from 'react'; import { getInternalApiClient, type Member } from '@/lib/api'; import { AUTH_ENV_VARS, getCredentialsManager } from '@/lib/auth'; -import { getConfigManager, type LinkedProject } from '@/lib/config'; +import { getProjectConfigManager, type ProjectConfig } from '@/lib/config'; import { imageToAscii } from '@/lib/utils/ascii-image'; import { Header } from '@/ui/components/Header'; import { StatusMessage } from '@/ui/components/StatusMessage'; @@ -30,12 +30,11 @@ async function fetchProfileAscii(profileImageUrl: string | undefined): Promise { +/** Get project config for workspace */ +async function getProjectConfig(workspacePath: string): Promise { try { - const configManager = getConfigManager(); - const config = await configManager.load(); - return config.workspaces?.[workspacePath] ?? null; + const projectConfigManager = getProjectConfigManager(workspacePath); + return await projectConfigManager.load(); } catch { return null; } @@ -52,7 +51,7 @@ export const WhoamiUI: React.FC = ({ onComplete }) => { const { exit } = useApp(); const [phase, setPhase] = useState('loading'); const [member, setMember] = useState(null); - const [linkedProject, setLinkedProject] = useState(null); + const [projectConfig, setProjectConfig] = useState(null); const [workspacePath] = useState(() => process.cwd()); const [isEnvAuth, setIsEnvAuth] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -94,8 +93,8 @@ export const WhoamiUI: React.FC = ({ onComplete }) => { const ascii = await fetchProfileAscii(memberInfo.profile_image_url); if (ascii) setProfileAscii(ascii); - const linked = await getLinkedProject(workspacePath); - if (linked) setLinkedProject(linked); + const config = await getProjectConfig(workspacePath); + if (config) setProjectConfig(config); setPhase('complete'); setResult({ status: 'ok', member: memberInfo }); @@ -126,15 +125,15 @@ export const WhoamiUI: React.FC = ({ onComplete }) => { )} - {linkedProject ? ( + {projectConfig ? ( <> Organization: - {linkedProject.organizationName} + {projectConfig.organization.name} Project: - {linkedProject.projectName} + {projectConfig.project.name} ) : ( @@ -147,7 +146,7 @@ export const WhoamiUI: React.FC = ({ onComplete }) => { )} - + Member ID: {member.id} diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index db11bf2..47900ba 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -2,6 +2,7 @@ import { Box, useApp } from 'ink'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { AgentInfo } from '../../lib/agents'; +import { getProjectConfigManager, type ProjectConfig } from '../../lib/config'; import type { InstallationMethod, UpdateCheckResult } from '../../lib/services/update-service'; import { AgentSelector } from '../components/AgentSelector'; import { DebugPrompt } from '../components/DebugPrompt'; @@ -46,6 +47,17 @@ const ChatAppInner: React.FC const { exit } = useApp(); const [updateInfo, setUpdateInfo] = useState(null); const [showUpdateNotification, setShowUpdateNotification] = useState(false); + const [projectConfig, setProjectConfig] = useState(null); + + // Load project config on mount + useEffect(() => { + const loadProjectConfig = async () => { + const configManager = getProjectConfigManager(); + const config = await configManager.load(); + setProjectConfig(config); + }; + loadProjectConfig(); + }, []); // Handle update check result useEffect(() => { @@ -159,7 +171,7 @@ const ChatAppInner: React.FC return ( - + {/* Update notification */} {showUpdateNotification && diff --git a/src/ui/chat/components/ChatHeader.tsx b/src/ui/chat/components/ChatHeader.tsx index 5795cac..bafc64c 100644 --- a/src/ui/chat/components/ChatHeader.tsx +++ b/src/ui/chat/components/ChatHeader.tsx @@ -1,6 +1,8 @@ import { Box, Text } from 'ink'; import type React from 'react'; import type { AgentInfo } from '../../../lib/agents'; +import type { ProjectConfig } from '../../../lib/config'; +import { formatPath } from '../../../lib/utils/path'; import { VERSION } from '../../../lib/version'; // Match Codex CLI inner width @@ -10,13 +12,21 @@ interface ChatHeaderProps { agent: AgentInfo | null; isStreaming?: boolean; directory?: string; + projectConfig?: ProjectConfig | null; } -export const ChatHeader: React.FC = ({ agent, directory = process.cwd() }) => { +export const ChatHeader: React.FC = ({ + agent, + directory = process.cwd(), + projectConfig, +}) => { + // Replace home directory with ~ for shorter display + const shortDir = formatPath(directory); + // Truncate directory path for display (leave room for "directory: " and padding) const maxDirLength = INNER_WIDTH - 13; const displayDir = - directory.length > maxDirLength ? `${directory.slice(0, maxDirLength - 3)}...` : directory; + shortDir.length > maxDirLength ? `${shortDir.slice(0, maxDirLength - 3)}...` : shortDir; const agentName = agent?.displayName ?? 'Not selected'; const horizontalBorder = '─'.repeat(INNER_WIDTH); @@ -35,6 +45,41 @@ export const ChatHeader: React.FC = ({ agent, directory = proce const dirText = dirPrefix + displayDir; const dirPadding = ' '.repeat(Math.max(0, INNER_WIDTH - dirText.length)); + // Project info (if configured) + const projectPrefix = ' project: '; + const projectName = projectConfig?.project.name ?? ''; + const orgName = projectConfig?.organization.name ?? ''; + const projectDisplay = projectConfig ? `${projectName} (${orgName})` : ''; + const maxProjectLength = INNER_WIDTH - projectPrefix.length - 1; + const truncatedProject = + projectDisplay.length > maxProjectLength + ? `${projectDisplay.slice(0, maxProjectLength - 3)}...` + : projectDisplay; + const projectText = projectPrefix + truncatedProject; + const projectPadding = ' '.repeat(Math.max(0, INNER_WIDTH - projectText.length)); + + // Project ID + const idPrefix = ' project_id:'; + const projectId = projectConfig?.project.id ?? ''; + const maxIdLength = INNER_WIDTH - idPrefix.length - 1; + const truncatedId = + projectId.length > maxIdLength ? `${projectId.slice(0, maxIdLength - 3)}...` : projectId; + const idText = idPrefix + truncatedId; + const idPadding = ' '.repeat(Math.max(0, INNER_WIDTH - idText.length)); + + // User info + const userPrefix = ' user: '; + const userName = projectConfig?.member.name ?? ''; + const userEmail = projectConfig?.member.email ?? ''; + const userDisplay = projectConfig ? `${userName} (${userEmail})` : ''; + const maxUserLength = INNER_WIDTH - userPrefix.length - 1; + const truncatedUser = + userDisplay.length > maxUserLength + ? `${userDisplay.slice(0, maxUserLength - 3)}...` + : userDisplay; + const userText = userPrefix + truncatedUser; + const userPadding = ' '.repeat(Math.max(0, INNER_WIDTH - userText.length)); + const emptyContent = ' '.repeat(INNER_WIDTH); return ( @@ -83,6 +128,36 @@ export const ChatHeader: React.FC = ({ agent, directory = proce + {/* Project line (if configured) */} + {projectConfig && ( + + │{projectPrefix} + {truncatedProject} + {projectPadding} + + + )} + + {/* Project ID line (if configured) */} + {projectConfig && ( + + │{idPrefix} + {truncatedId} + {idPadding} + + + )} + + {/* User line (if configured) */} + {projectConfig && ( + + │{userPrefix} + {truncatedUser} + {userPadding} + + + )} + {/* Bottom border */} ╰{horizontalBorder}╯ diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx index 5b972d9..a17f645 100644 --- a/src/ui/components/FirebaseWizard.tsx +++ b/src/ui/components/FirebaseWizard.tsx @@ -5,6 +5,7 @@ import TextInput from 'ink-text-input'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { openBrowser } from '@/lib/auth/browser'; +import type { ProjectType } from '@/lib/config'; import { type AndroidApp, type CredentialAction, @@ -20,6 +21,7 @@ import { platformNeedsIos, type WizardPhase, } from '@/lib/services/firebase'; +import { detectProjectType } from '@/lib/services/project-detector'; import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; import { useCancelInput } from '@/ui/hooks'; import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; @@ -27,6 +29,7 @@ import { GenericSelector, type SelectorItem } from './GenericSelector'; interface FirebaseWizardProps { projectPath: string; + projectType?: ProjectType; onComplete: (result: FirebaseSetupResult) => void; onCancel?: () => void; } @@ -801,6 +804,7 @@ function CompletePhase({ */ export const FirebaseWizard: React.FC = ({ projectPath, + projectType: propProjectType, onComplete, onCancel, }) => { @@ -825,13 +829,18 @@ export const FirebaseWizard: React.FC = ({ const [noAppsContext, setNoAppsContext] = useState(null); const [creatingAppPlatform, setCreatingAppPlatform] = useState<'android' | 'ios'>('android'); - const [service] = useState(() => new FirebaseService(projectPath)); + const [service, setService] = useState(null); + const [projectType, setProjectType] = useState(propProjectType ?? null); // Initial detection useEffect(() => { const detect = async () => { try { - const detectionResult = await service.detect(); + const detectedProjectType = propProjectType ?? (await detectProjectType(projectPath)); + setProjectType(detectedProjectType); + const firebaseService = new FirebaseService(projectPath, detectedProjectType); + setService(firebaseService); + const detectionResult = await firebaseService.detect(); setResult(detectionResult); setPhase('status'); } catch (err) { @@ -843,7 +852,7 @@ export const FirebaseWizard: React.FC = ({ if (phase === 'detecting') { detect(); } - }, [phase, service]); + }, [phase, projectPath, propProjectType]); const handleContinue = useCallback(() => { setPhase('menu'); @@ -872,6 +881,12 @@ export const FirebaseWizard: React.FC = ({ // Download config files - defined first as it's called by other handlers via ref const handleDownloadConfigs = useCallback( async (project: FirebaseProject, androidApp: AndroidApp | null, iosApp: IosApp | null) => { + if (!projectType) { + setError('Project type not detected'); + setPhase('error'); + return; + } + if (androidApp && iosApp) { setDownloadingPlatform('both'); } else if (androidApp) { @@ -882,7 +897,7 @@ export const FirebaseWizard: React.FC = ({ setPhase('downloading'); try { - const paths = await downloader.getExpectedSavePaths(projectPath); + const paths = downloader.getExpectedSavePaths(projectPath, projectType); if (androidApp && paths.android) { await downloader.downloadAndroidConfig( @@ -897,15 +912,17 @@ export const FirebaseWizard: React.FC = ({ } // Re-detect to verify - const newResult = await service.detect(); - setResult(newResult); + if (service) { + const newResult = await service.detect(); + setResult(newResult); + } setPhase('status'); } catch (err) { setError(err instanceof Error ? err.message : 'Download failed'); setPhase('error'); } }, - [projectPath, downloader, service], + [projectPath, projectType, downloader, service], ); // Use ref to avoid circular dependencies @@ -1134,8 +1151,10 @@ export const FirebaseWizard: React.FC = ({ setPhase('validating'); // Re-detect to validate try { - const newResult = await service.detect(); - setResult(newResult); + if (service) { + const newResult = await service.detect(); + setResult(newResult); + } setPhase('status'); } catch (err) { setError(err instanceof Error ? err.message : 'Validation failed'); diff --git a/src/ui/components/ProjectSelector.tsx b/src/ui/components/ProjectSelector.tsx index 9a3799e..1fa2a1b 100644 --- a/src/ui/components/ProjectSelector.tsx +++ b/src/ui/components/ProjectSelector.tsx @@ -18,6 +18,8 @@ export interface ProjectSelectorProps { onSelect: (project: Project, org: Organization) => void; onSkip: () => void; workspacePath: string; + /** Whether to show skip option (default: true) */ + showSkip?: boolean; } /** @@ -38,6 +40,7 @@ export const ProjectSelector: React.FC = ({ onSelect, onSkip, workspacePath, + showSkip = true, }) => { const flattenedProjects = useMemo(() => flattenProjects(organizations), [organizations]); const [selectedIndex, setSelectedIndex] = useState(0); @@ -79,7 +82,7 @@ export const ProjectSelector: React.FC = ({ useInput((_input, key) => { // Handle empty list - only Enter/Esc work if (totalItems === 0) { - if (key.return || key.escape) { + if (key.return || (showSkip && key.escape)) { onSkip(); } return; @@ -94,7 +97,7 @@ export const ProjectSelector: React.FC = ({ if (selected) { onSelect(selected.project, selected.org); } - } else if (key.escape) { + } else if (showSkip && key.escape) { onSkip(); } }); @@ -148,7 +151,7 @@ export const ProjectSelector: React.FC = ({ )} - ↑↓ to navigate · Enter to select · Esc to skip + ↑↓ to navigate · Enter to select{showSkip ? ' · Esc to skip' : ''} ); diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx index d3975a2..8db8786 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/PushSetupWizard.tsx @@ -32,6 +32,7 @@ import { isOAuthConfigured, } from '@/lib/services/firebase'; import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; +import { detectProjectType } from '@/lib/services/project-detector'; import { isCtrlCInput, useCancelInput } from '@/ui/hooks'; import { AppleLoginUI } from './AppleLoginUI'; @@ -842,7 +843,8 @@ async function detectFromFirebase(projectPath: string): Promise }; try { - const firebaseService = new FirebaseService(projectPath); + const projectType = await detectProjectType(projectPath); + const firebaseService = new FirebaseService(projectPath, projectType); const detection = await firebaseService.detect(); const iosContent = detection.ios?.content as GoogleServiceInfoPlist | undefined; diff --git a/tests/e2e/test-rig.ts b/tests/e2e/test-rig.ts index 2b18801..8d24085 100644 --- a/tests/e2e/test-rig.ts +++ b/tests/e2e/test-rig.ts @@ -41,6 +41,8 @@ export class CLITestRig { ...options?.env, // Force non-interactive mode CI: 'true', + // Skip first-run setup for E2E tests + CLIX_SKIP_SETUP: '1', }, stdout: 'pipe', stderr: 'pipe',