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:
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user