Add profile to twenty-cli authentication (#15778)

As title
Eg: 
<img width="641" height="45" alt="image"
src="https://github.com/user-attachments/assets/0ac159db-ebcf-4c21-af1b-b089ee3b39f8"
/>

Not breaking
This commit is contained in:
martmull
2025-11-13 15:08:53 +01:00
committed by GitHub
parent 0cab2b49fc
commit ab967bf81f
7 changed files with 100 additions and 176 deletions
@@ -4,5 +4,4 @@ export const testConfig: TwentyConfig = {
apiUrl: 'http://localhost:3000',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
defaultApp: 'e2e-default-app',
};
@@ -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);
+15 -5
View File
@@ -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 <url>',
'Twenty API URL',
process.env.TWENTY_API_URL || 'http://localhost:3000',
'--workspace <name>',
'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();
@@ -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<void> {
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<void> {
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'}`,
@@ -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 <key> <value>')
.description('Set configuration value')
.action(async (key, value) => {
await this.set(key, value);
});
configCommand
.command('unset <key>')
.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<void> {
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<void> {
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<void> {
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<void> {
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<string, any>): 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'));
}
}
@@ -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<string, TwentyConfig>;
};
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<PersistedConfig> {
await fs.ensureFile(this.configPath);
const content = await fs.readFile(this.configPath, 'utf8');
return JSON.parse(content || '{}');
}
async getConfig(): Promise<TwentyConfig> {
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<TwentyConfig>): Promise<void> {
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<void> {
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 {
@@ -1,7 +1,6 @@
export interface TwentyConfig {
apiUrl: string;
apiKey?: string;
defaultApp?: string;
}
export type PackageJson = {