From ab967bf81f8a8f86f30def2a7ea7803c7c741d6b Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 13 Nov 2025 15:08:53 +0100 Subject: [PATCH] Add profile to twenty-cli authentication (#15778) As title Eg: image Not breaking --- .../src/__tests__/e2e/constants/testConfig.ts | 1 - .../src/__tests__/e2e/health.e2e-spec.ts | 9 -- packages/twenty-cli/src/cli.ts | 20 ++- .../twenty-cli/src/commands/auth.command.ts | 16 +- .../twenty-cli/src/commands/config.command.ts | 140 ------------------ .../twenty-cli/src/services/config.service.ts | 89 ++++++++--- packages/twenty-cli/src/types/config.types.ts | 1 - 7 files changed, 100 insertions(+), 176 deletions(-) delete mode 100644 packages/twenty-cli/src/commands/config.command.ts diff --git a/packages/twenty-cli/src/__tests__/e2e/constants/testConfig.ts b/packages/twenty-cli/src/__tests__/e2e/constants/testConfig.ts index 9335c60f9e..66f0a4d6aa 100644 --- a/packages/twenty-cli/src/__tests__/e2e/constants/testConfig.ts +++ b/packages/twenty-cli/src/__tests__/e2e/constants/testConfig.ts @@ -4,5 +4,4 @@ export const testConfig: TwentyConfig = { apiUrl: 'http://localhost:3000', apiKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik', - defaultApp: 'e2e-default-app', }; diff --git a/packages/twenty-cli/src/__tests__/e2e/health.e2e-spec.ts b/packages/twenty-cli/src/__tests__/e2e/health.e2e-spec.ts index f7f012e88d..0e7ec1eb41 100644 --- a/packages/twenty-cli/src/__tests__/e2e/health.e2e-spec.ts +++ b/packages/twenty-cli/src/__tests__/e2e/health.e2e-spec.ts @@ -1,18 +1,9 @@ import axios from 'axios'; -import { ConfigService } from '../../services/config.service'; import { SERVER_URL } from './constants/server-url.constant'; describe('Twenty Server Health Check (E2E)', () => { - const configService = new ConfigService(); const HEALTH_ENDPOINT = `${SERVER_URL}/healthz`; - it('should assert e2e configuration is loaded by default', async () => { - const configuration = await configService.getConfig(); - expect(configuration).toMatchObject({ - defaultApp: 'e2e-default-app', - }); - }); - it('should return 200 for health', async () => { const response = await axios.get(HEALTH_ENDPOINT); expect(response.status).toBe(200); diff --git a/packages/twenty-cli/src/cli.ts b/packages/twenty-cli/src/cli.ts index 0e0ee88f4b..b370cff5ab 100644 --- a/packages/twenty-cli/src/cli.ts +++ b/packages/twenty-cli/src/cli.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { AppCommand } from './commands/app.command'; import { AuthCommand } from './commands/auth.command'; -import { ConfigCommand } from './commands/config.command'; +import { ConfigService } from './services/config.service'; const packageJson = JSON.parse( readFileSync(join(__dirname, '../package.json'), 'utf-8'), @@ -20,14 +20,24 @@ program .version(packageJson.version); program.option( - '--api-url ', - 'Twenty API URL', - process.env.TWENTY_API_URL || 'http://localhost:3000', + '--workspace ', + 'Use a specific workspace configuration', + 'default', ); +program.hook('preAction', (thisCommand) => { + const opts = (thisCommand as any).optsWithGlobals + ? (thisCommand as any).optsWithGlobals() + : thisCommand.opts(); + const workspace = opts.workspace; + ConfigService.setActiveWorkspace(workspace); + console.log( + chalk.gray(`👩‍💻 Workspace - ${ConfigService.getActiveWorkspace()}`), + ); +}); + program.addCommand(new AuthCommand().getCommand()); program.addCommand(new AppCommand().getCommand()); -program.addCommand(new ConfigCommand().getCommand()); program.exitOverride(); diff --git a/packages/twenty-cli/src/commands/auth.command.ts b/packages/twenty-cli/src/commands/auth.command.ts index 5243cbedd5..84be8ecca8 100644 --- a/packages/twenty-cli/src/commands/auth.command.ts +++ b/packages/twenty-cli/src/commands/auth.command.ts @@ -92,7 +92,12 @@ export class AuthCommand { const isValid = await this.apiService.validateAuth(); if (isValid) { - console.log(chalk.green('✓ Successfully authenticated with Twenty')); + const activeWorkspace = ConfigService.getActiveWorkspace(); + console.log( + chalk.green( + `✓ Successfully authenticated with Twenty (workspace: ${activeWorkspace})`, + ), + ); } else { console.log( chalk.red('✗ Authentication failed. Please check your credentials.'), @@ -111,7 +116,12 @@ export class AuthCommand { private async logout(): Promise { try { await this.configService.clearConfig(); - console.log(chalk.green('✓ Successfully logged out')); + const activeWorkspace = ConfigService.getActiveWorkspace(); + console.log( + chalk.green( + `✓ Successfully logged out (workspace: ${activeWorkspace})`, + ), + ); } catch (error) { console.error( chalk.red('Logout failed:'), @@ -123,9 +133,11 @@ export class AuthCommand { private async status(): Promise { try { + const activeWorkspace = ConfigService.getActiveWorkspace(); const config = await this.configService.getConfig(); console.log(chalk.blue('Authentication Status:')); + console.log(`Workspace: ${activeWorkspace}`); console.log(`API URL: ${config.apiUrl}`); console.log( `API Key: ${config.apiKey ? '***' + config.apiKey.slice(-4) : 'Not set'}`, diff --git a/packages/twenty-cli/src/commands/config.command.ts b/packages/twenty-cli/src/commands/config.command.ts deleted file mode 100644 index 01a1fa0828..0000000000 --- a/packages/twenty-cli/src/commands/config.command.ts +++ /dev/null @@ -1,140 +0,0 @@ -import chalk from 'chalk'; -import { Command } from 'commander'; -import { ConfigService } from '../services/config.service'; -import { TwentyConfig } from '../types/config.types'; - -export class ConfigCommand { - private configService = new ConfigService(); - - getCommand(): Command { - const configCommand = new Command('config'); - configCommand.description('Configuration management'); - - configCommand - .command('get [key]') - .description('Get configuration value(s)') - .action(async (key) => { - await this.get(key); - }); - - configCommand - .command('set ') - .description('Set configuration value') - .action(async (key, value) => { - await this.set(key, value); - }); - - configCommand - .command('unset ') - .description('Remove configuration value') - .action(async (key) => { - await this.unset(key); - }); - - configCommand - .command('list') - .description('List all configuration values') - .action(async () => { - await this.list(); - }); - - return configCommand; - } - - private async get(key: string | undefined): Promise { - try { - const config = await this.configService.getConfig(); - - if (key) { - const value = (config as any)[key]; - if (value !== undefined) { - console.log(value); - } else { - console.log(chalk.gray('(not set)')); - } - } else { - this.printConfig('Configuration', config); - } - } catch (error) { - console.error( - chalk.red('Failed to get configuration:'), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } - } - - private async set(key: keyof TwentyConfig, value: string): Promise { - try { - const config = await this.configService.getConfig(); - config[key] = value; - await this.configService.setConfig(config); - console.log(chalk.green(`✓ Set ${key} in configuration`)); - } catch (error) { - console.error( - chalk.red('Failed to set configuration:'), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } - } - - private async unset(key: string): Promise { - try { - const config = await this.configService.getConfig(); - delete (config as any)[key]; - await this.configService.setConfig(config); - console.log(chalk.green(`✓ Removed ${key} from configuration`)); - } catch (error) { - console.error( - chalk.red('Failed to unset configuration:'), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } - } - - private async list(): Promise { - try { - const config = await this.configService.getConfig(); - this.printConfig('Configuration', config); - } catch (error) { - console.error( - chalk.red('Failed to list configuration:'), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } - } - - private printConfig(title: string, config: Record): void { - console.log(chalk.blue(title + ':')); - - const keys = Object.keys(config); - if (keys.length === 0) { - console.log(chalk.gray(' (empty)')); - return; - } - - keys.forEach((key) => { - const value = config[key]; - const displayValue = - key.toLowerCase().includes('key') && value - ? '***' + value.slice(-4) - : value; - - // Show if value is overridden by environment variable - const envVarName = `TWENTY_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`; - const isOverridden = process.env[envVarName] !== undefined; - const suffix = isOverridden ? chalk.gray(' (from env)') : ''; - - console.log(` ${key}: ${displayValue}${suffix}`); - }); - - // Show available environment variables - console.log(chalk.gray('\nEnvironment variables:')); - console.log(chalk.gray(' TWENTY_API_URL - Override API URL')); - console.log(chalk.gray(' TWENTY_API_KEY - Override API key')); - console.log(chalk.gray(' TWENTY_DEFAULT_APP - Override default app')); - } -} diff --git a/packages/twenty-cli/src/services/config.service.ts b/packages/twenty-cli/src/services/config.service.ts index 2643faca84..b2961fc466 100644 --- a/packages/twenty-cli/src/services/config.service.ts +++ b/packages/twenty-cli/src/services/config.service.ts @@ -3,49 +3,102 @@ import * as os from 'os'; import * as path from 'path'; import { TwentyConfig } from '../types/config.types'; +type PersistedConfig = TwentyConfig & { + profiles?: Record; +}; + +const DEFAULT_WORKSPACE_NAME = 'default'; + export class ConfigService { private readonly configPath: string; + private static activeWorkspace = DEFAULT_WORKSPACE_NAME; constructor() { this.configPath = path.join(os.homedir(), '.twenty', 'config.json'); } + static setActiveWorkspace(name?: string) { + this.activeWorkspace = name ?? DEFAULT_WORKSPACE_NAME; + } + + static getActiveWorkspace(): string { + return this.activeWorkspace; + } + + private getActiveWorkspaceName(): string { + return ConfigService.getActiveWorkspace(); + } + + private async readRawConfig(): Promise { + await fs.ensureFile(this.configPath); + const content = await fs.readFile(this.configPath, 'utf8'); + return JSON.parse(content || '{}'); + } + async getConfig(): Promise { + const defaultConfig = this.getDefaultConfig(); try { - const defaultConfig = this.getDefaultConfig(); + const raw = await this.readRawConfig(); + const profile = this.getActiveWorkspaceName(); - let fileConfig = {}; - await fs.ensureFile(this.configPath); - const configExists = await fs.pathExists(this.configPath); + const profileConfig = + profile === DEFAULT_WORKSPACE_NAME && + !raw.profiles?.[DEFAULT_WORKSPACE_NAME] + ? raw + : raw.profiles?.[profile]; - if (configExists) { - const configContent = await fs.readFile(this.configPath, 'utf8'); - // TODO parse using a zod schema - fileConfig = JSON.parse(configContent || '{}'); - } + // Fallback to legacy top-level values if profile value is missing + const apiUrl = profileConfig?.apiUrl ?? defaultConfig.apiUrl; + const apiKey = profileConfig?.apiKey; return { - ...defaultConfig, - ...fileConfig, + apiUrl, + apiKey, }; } catch { - return this.getDefaultConfig(); + return defaultConfig; } } async setConfig(config: Partial): Promise { - const currentConfig = await this.getConfig(); - const newConfig = { ...currentConfig, ...config }; + const raw = await this.readRawConfig(); + const profile = this.getActiveWorkspaceName(); + + // Ensure profiles map exists + if (!raw.profiles) { + raw.profiles = {}; + } + + const currentProfile = raw.profiles[profile] || {}; + + raw.profiles[profile] = { ...currentProfile, ...config }; await fs.ensureDir(path.dirname(this.configPath)); - await fs.writeFile(this.configPath, JSON.stringify(newConfig, null, 2)); + await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2)); } async clearConfig(): Promise { - const configExists = await fs.pathExists(this.configPath); - if (configExists) { - await fs.remove(this.configPath); + // Clear only the active profile credentials (non-breaking for other profiles) + const raw = await this.readRawConfig(); + const profile = this.getActiveWorkspaceName(); + + if (!raw.profiles) { + raw.profiles = {}; } + + if (raw.profiles[profile]) { + delete raw.profiles[profile]; + } + + // Also clear legacy top-level apiKey for compatibility when active profile is default + if (profile === DEFAULT_WORKSPACE_NAME) { + const defaultConfig = this.getDefaultConfig(); + delete raw.apiKey; + raw.apiUrl = defaultConfig.apiUrl; + } + + await fs.ensureDir(path.dirname(this.configPath)); + await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2)); } private getDefaultConfig(): TwentyConfig { diff --git a/packages/twenty-cli/src/types/config.types.ts b/packages/twenty-cli/src/types/config.types.ts index 9ee4946326..4c1755b692 100644 --- a/packages/twenty-cli/src/types/config.types.ts +++ b/packages/twenty-cli/src/types/config.types.ts @@ -1,7 +1,6 @@ export interface TwentyConfig { apiUrl: string; apiKey?: string; - defaultApp?: string; } export type PackageJson = {