2093 extensibility add twenty auth switch command (#17286)

add auth:switch and auth:list commands
This commit is contained in:
martmull
2026-01-20 16:12:59 +01:00
committed by GitHub
parent e599d6c4d0
commit 579c59bd11
9 changed files with 264 additions and 958 deletions
@@ -195,6 +195,8 @@ const createPackageJson = async ({
'auth:login': 'twenty auth:login',
'auth:logout': 'twenty auth:logout',
'auth:status': 'twenty auth:status',
'auth:switch': 'twenty auth:switch',
'auth:list': 'twenty auth:list',
'app:dev': 'twenty app:dev',
'app:sync': 'twenty app:sync',
'entity:add': 'twenty entity:add',
File diff suppressed because one or more lines are too long
@@ -173,18 +173,32 @@ The first time you run `yarn auth:login`, you'll be prompted for:
- API URL (defaults to http://localhost:3000 or your current workspace profile)
- API key
Your credentials are stored per-user in `~/.twenty/config.json`. You can maintain multiple profiles and switch using `--workspace <name>`.
Your credentials are stored per-user in `~/.twenty/config.json`. You can maintain multiple profiles and switch between them.
Examples:
### Managing workspaces
```bash filename="Terminal"
# Login interactively (recommended)
yarn auth:login
# Use a specific workspace profile
# Login to a specific workspace profile
yarn auth:login --workspace my-custom-workspace
# List all configured workspaces
yarn auth:list
# Switch the default workspace (interactive)
yarn auth:switch
# Switch to a specific workspace
yarn auth:switch production
# Check current authentication status
yarn auth:status
```
Once you've switched workspaces with `auth:switch`, all subsequent commands will use that workspace by default. You can still override it temporarily with `--workspace <name>`.
## Use the SDK resources (types & config)
The twenty-sdk provides typed building blocks and helper functions you use inside your app. Below are the key pieces you'll touch most often.
+22 -2
View File
@@ -68,6 +68,14 @@ Authenticate the CLI against your Twenty workspace.
- `twenty auth:status` — Print the current authentication status (API URL, masked API key, validity).
- `twenty auth:list` — List all configured workspaces.
- Behavior: Displays all available workspaces with their authentication status and API URLs. Shows which workspace is the current default.
- `twenty auth:switch [workspace]` — Switch the default workspace for authentication.
- Arguments:
- `workspace` (optional): Name of the workspace to switch to. If omitted, shows an interactive selection.
- Behavior: Sets the specified workspace as the default, so subsequent commands use it without needing `--workspace`.
Examples:
```bash
@@ -85,6 +93,15 @@ twenty auth:status
# Logout current profile
twenty auth:logout
# List all configured workspaces
twenty auth:list
# Switch default workspace interactively
twenty auth:switch
# Switch to a specific workspace
twenty auth:switch production
```
### App
@@ -166,12 +183,13 @@ twenty function:execute -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "val
The CLI stores configuration per user in a JSON file:
- Location: `~/.twenty/config.json`
- Structure: Profiles keyed by workspace name. The active profile is selected with `--workspace <name>`.
- Structure: Profiles keyed by workspace name. The active profile is selected with `--workspace <name>` or by the `defaultWorkspace` setting.
Example configuration file:
```json
{
"defaultWorkspace": "prod",
"profiles": {
"default": {
"apiUrl": "http://localhost:3000",
@@ -188,8 +206,10 @@ Example configuration file:
Notes:
- If a profile is missing, `apiUrl` defaults to `http://localhost:3000` until set.
- `twenty auth:login` writes the `apiUrl` and `apiKey` for the default profile.
- `twenty auth:login` writes the `apiUrl` and `apiKey` for the active workspace profile.
- `twenty auth:login --workspace custom-workspace` writes the `apiUrl` and `apiKey` for a custom `custom-workspace` profile.
- `twenty auth:switch` sets the `defaultWorkspace` field, which is used when `--workspace` is not specified.
- `twenty auth:list` shows all configured workspaces and their authentication status.
## Troubleshooting
+10 -4
View File
@@ -17,15 +17,21 @@ program
program.option(
'--workspace <name>',
'Use a specific workspace configuration',
'default',
'Use a specific workspace configuration (overrides the default set by auth:switch)',
);
program.hook('preAction', (thisCommand) => {
program.hook('preAction', async (thisCommand) => {
const opts = (thisCommand as any).optsWithGlobals
? (thisCommand as any).optsWithGlobals()
: thisCommand.opts();
const workspace = opts.workspace;
// If --workspace is provided, use it; otherwise, read the persisted default
let workspace = opts.workspace;
if (!workspace) {
const configService = new ConfigService();
workspace = await configService.getDefaultWorkspace();
}
ConfigService.setActiveWorkspace(workspace);
console.log(
chalk.gray(`👩‍💻 Workspace - ${ConfigService.getActiveWorkspace()}`),
@@ -6,11 +6,13 @@ import { AppDevCommand } from './app/app-dev';
import { AppGenerateCommand } from './app/app-generate';
import { AppSyncCommand } from './app/app-sync';
import { AppUninstallCommand } from './app/app-uninstall';
import { AuthListCommand } from './auth/auth-list';
import { AuthLoginCommand } from './auth/auth-login';
import { AuthLogoutCommand } from './auth/auth-logout';
import { AuthStatusCommand } from './auth/auth-status';
import { FunctionExecuteCommand } from './function/function-execute';
import { FunctionLogsCommand } from './function/function-logs';
import { AuthSwitchCommand } from './auth/auth-switch';
import {
EntityAddCommand,
isSyncableEntity,
@@ -19,9 +21,11 @@ import {
export const registerCommands = (program: Command): void => {
// Auth commands
const listCommand = new AuthListCommand();
const loginCommand = new AuthLoginCommand();
const logoutCommand = new AuthLogoutCommand();
const statusCommand = new AuthStatusCommand();
const switchCommand = new AuthSwitchCommand();
program
.command('auth:login')
@@ -46,6 +50,20 @@ export const registerCommands = (program: Command): void => {
await statusCommand.execute();
});
program
.command('auth:switch [workspace]')
.description('Switch the default workspace for authentication')
.action(async (workspace?: string) => {
await switchCommand.execute({ workspace });
});
program
.command('auth:list')
.description('List all configured workspaces')
.action(async () => {
await listCommand.execute();
});
// App commands
const devCommand = new AppDevCommand();
const syncCommand = new AppSyncCommand();
@@ -0,0 +1,51 @@
import chalk from 'chalk';
import { ConfigService } from '@/cli/utilities/config/services/config.service';
export class AuthListCommand {
private configService = new ConfigService();
async execute(): Promise<void> {
try {
const availableWorkspaces =
await this.configService.getAvailableWorkspaces();
const currentDefault = await this.configService.getDefaultWorkspace();
if (availableWorkspaces.length === 0) {
console.log(
chalk.yellow(
'⚠ No workspaces configured. Use `twenty auth:login` to create one.',
),
);
return;
}
console.log(chalk.blue('Available workspaces:\n'));
for (const workspace of availableWorkspaces) {
const config = await this.configService.getConfigForWorkspace(workspace);
const hasCredentials = !!config.apiKey;
const isDefault = workspace === currentDefault;
const defaultIndicator = isDefault ? chalk.green(' (default)') : '';
const credentialStatus = hasCredentials
? chalk.green('●')
: chalk.gray('○');
console.log(` ${credentialStatus} ${workspace}${defaultIndicator}`);
console.log(chalk.gray(` API URL: ${config.apiUrl}`));
}
console.log('');
console.log(chalk.gray('● = authenticated, ○ = no credentials'));
console.log(
chalk.gray('Use `twenty auth:switch <workspace>` to change default'),
);
} catch (error) {
console.error(
chalk.red('List failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
}
@@ -1,8 +1,106 @@
// Placeholder for auth switch command
// TODO: Implement workspace switching functionality
import chalk from 'chalk';
import inquirer from 'inquirer';
import { ApiService } from '@/cli/utilities/api/services/api.service';
import { ConfigService } from '@/cli/utilities/config/services/config.service';
export class AuthSwitchCommand {
async execute(): Promise<void> {
throw new Error('Not implemented');
private configService = new ConfigService();
private apiService = new ApiService();
async execute(options: { workspace?: string }): Promise<void> {
try {
let { workspace } = options;
const availableWorkspaces =
await this.configService.getAvailableWorkspaces();
const currentDefault = await this.configService.getDefaultWorkspace();
if (availableWorkspaces.length === 0) {
console.log(
chalk.yellow(
'⚠ No workspaces configured. Use `twenty auth:login` to create one.',
),
);
return;
}
// If workspace is not provided, show interactive selection
if (!workspace) {
// Build choices with indicators for current default
const choices = availableWorkspaces.map((ws) => ({
name: ws === currentDefault ? `${ws} (current default)` : ws,
value: ws,
}));
const answer = await inquirer.prompt([
{
type: 'list',
name: 'workspace',
message: 'Select a workspace to set as default:',
choices,
default: currentDefault,
},
]);
workspace = answer.workspace as string;
}
// Validate that the workspace exists (workspace is guaranteed to be defined here)
if (!availableWorkspaces.includes(workspace!)) {
console.log(
chalk.red(
`✗ Workspace "${workspace}" not found. Available workspaces: ${availableWorkspaces.join(', ')}`,
),
);
process.exit(1);
}
// If already the default, inform and exit
if (workspace === currentDefault) {
console.log(
chalk.blue(` "${workspace}" is already the default workspace.`),
);
return;
}
// Set the new default workspace
await this.configService.setDefaultWorkspace(workspace!);
// Also set it as active for the current session to validate
ConfigService.setActiveWorkspace(workspace);
// Check authentication status for the new workspace
const config = await this.configService.getConfig();
const hasCredentials = !!config.apiKey;
console.log(
chalk.green(`✓ Switched default workspace to "${workspace}"`),
);
if (hasCredentials) {
const isValid = await this.apiService.validateAuth();
if (isValid) {
console.log(chalk.green('✓ Authentication is valid'));
} else {
console.log(
chalk.yellow(
'⚠ Authentication credentials exist but are invalid. Run `twenty auth:login` to re-authenticate.',
),
);
}
} else {
console.log(
chalk.yellow(
'⚠ No credentials configured for this workspace. Run `twenty auth:login` to authenticate.',
),
);
}
} catch (error) {
console.error(
chalk.red('Switch failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
}
@@ -9,6 +9,7 @@ export type TwentyConfig = {
type PersistedConfig = TwentyConfig & {
profiles?: Record<string, TwentyConfig>;
defaultWorkspace?: string;
};
const DEFAULT_WORKSPACE_NAME = 'default';
@@ -40,16 +41,19 @@ export class ConfigService {
}
async getConfig(): Promise<TwentyConfig> {
return this.getConfigForWorkspace(this.getActiveWorkspaceName());
}
async getConfigForWorkspace(workspaceName: string): Promise<TwentyConfig> {
const defaultConfig = this.getDefaultConfig();
try {
const raw = await this.readRawConfig();
const profile = this.getActiveWorkspaceName();
const profileConfig =
profile === DEFAULT_WORKSPACE_NAME &&
workspaceName === DEFAULT_WORKSPACE_NAME &&
!raw.profiles?.[DEFAULT_WORKSPACE_NAME]
? raw
: raw.profiles?.[profile];
: raw.profiles?.[workspaceName];
// Fallback to legacy top-level values if profile value is missing
const apiUrl = profileConfig?.apiUrl ?? defaultConfig.apiUrl;
@@ -110,4 +114,39 @@ export class ConfigService {
apiUrl: 'http://localhost:3000',
};
}
async getAvailableWorkspaces(): Promise<string[]> {
try {
const raw = await this.readRawConfig();
const workspaces = new Set<string>();
// Always include the default workspace
workspaces.add(DEFAULT_WORKSPACE_NAME);
// Add all profiles
if (raw.profiles) {
Object.keys(raw.profiles).forEach((name) => workspaces.add(name));
}
return Array.from(workspaces).sort();
} catch {
return [DEFAULT_WORKSPACE_NAME];
}
}
async getDefaultWorkspace(): Promise<string> {
try {
const raw = await this.readRawConfig();
return raw.defaultWorkspace ?? DEFAULT_WORKSPACE_NAME;
} catch {
return DEFAULT_WORKSPACE_NAME;
}
}
async setDefaultWorkspace(name: string): Promise<void> {
const raw = await this.readRawConfig();
raw.defaultWorkspace = name;
await fs.ensureDir(path.dirname(this.configPath));
await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2));
}
}