Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/workato_platform_cli/cli/commands/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,5 +360,53 @@ async def delete(
click.echo(f"❌ Failed to delete profile '{profile_name}'")


@profiles.command()
@click.argument("profile_name")
@handle_cli_exceptions
@inject
async def create(
profile_name: str,
config_manager: ConfigManager = Provide[Container.config_manager],
) -> None:
"""Create a new profile with API credentials"""
# Check if profile already exists
existing_profile = config_manager.profile_manager.get_profile(profile_name)
if existing_profile:
click.echo(f"❌ Profile '{profile_name}' already exists")
click.echo("💡 Use 'workato profiles use' to switch to it")
click.echo("💡 Or use 'workato profiles delete' to remove it first")
return

click.echo(f"🔧 Creating profile: {profile_name}")
click.echo()

# Create profile interactively
try:
(
profile_data,
token,
) = await config_manager.profile_manager.create_profile_interactive(
profile_name
)
except click.ClickException:
click.echo("❌ Profile creation cancelled")
return

# Save profile
try:
config_manager.profile_manager.set_profile(profile_name, profile_data, token)
except ValueError as e:
click.echo(f"❌ Failed to save profile: {e}")
return

# Set as current profile
config_manager.profile_manager.set_current_profile(profile_name)

click.echo(f"✅ Profile '{profile_name}' created successfully")
click.echo(f"✅ Set '{profile_name}' as the active profile")
click.echo()
click.echo("💡 You can now use this profile with Workato CLI commands")


# Add show as click argument command
show = click.argument("profile_name")(show)
35 changes: 3 additions & 32 deletions src/workato_platform_cli/cli/utils/config/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,38 +544,9 @@ async def _prompt_and_validate_credentials(

async def _create_new_profile(self, profile_name: str) -> None:
"""Create a new profile interactively"""
# Select region
click.echo("📍 Select your Workato region")
region_result = await self.profile_manager.select_region_interactive()

if not region_result:
raise click.ClickException("Setup cancelled")

selected_region = region_result

# Get API token
token = await asyncio.to_thread(
get_token_with_smart_paste,
prompt_text="API token",
)
if not token.strip():
raise click.ClickException("API token cannot be empty")

# Test authentication and get workspace info
api_config = Configuration(
access_token=token, host=selected_region.url, ssl_ca_cert=certifi.where()
)

async with Workato(configuration=api_config) as workato_api_client:
user_info = await workato_api_client.users_api.get_workspace_details()

# Create and save profile
if not selected_region.url:
raise click.ClickException("Region URL is required")
profile_data = ProfileData(
region=selected_region.region,
region_url=selected_region.url,
workspace_id=user_info.id,
# Create profile using shared helper
profile_data, token = await self.profile_manager.create_profile_interactive(
profile_name
)

# Save profile and token
Expand Down
65 changes: 65 additions & 0 deletions src/workato_platform_cli/cli/utils/config/profiles.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Profile management for multiple Workato environments."""

import asyncio
import contextlib
import json
import os
Expand All @@ -9,13 +10,18 @@
from urllib.parse import urlparse

import asyncclick as click
import certifi
import inquirer
import keyring

from keyring.backend import KeyringBackend
from keyring.compat import properties
from keyring.errors import KeyringError, NoKeyringError

from workato_platform_cli import Workato
from workato_platform_cli.cli.utils.token_input import get_token_with_smart_paste
from workato_platform_cli.client.workato_api.configuration import Configuration

from .models import AVAILABLE_REGIONS, ProfileData, ProfilesConfig, RegionInfo


Expand Down Expand Up @@ -489,3 +495,62 @@ async def select_region_interactive(
return RegionInfo(region="custom", name="Custom URL", url=custom_url)

return selected_region

async def create_profile_interactive(
self, profile_name: str
) -> tuple[ProfileData, str]:
"""Create a new profile with interactive prompts for region and token.

Performs region selection, token input, credential validation, and returns
the validated profile data and token.

Args:
profile_name: Name of the profile being created

Returns:
tuple[ProfileData, str]: The validated profile data and API token

Raises:
click.ClickException: If setup is cancelled, token is empty,
or validation fails
"""
# Step 1: Select region
click.echo("📍 Select your Workato region")
selected_region = await self.select_region_interactive(profile_name)

if not selected_region:
raise click.ClickException("Setup cancelled")

# Step 2: Get API token
token = await asyncio.to_thread(
get_token_with_smart_paste,
prompt_text="API token",
)
if not token.strip():
raise click.ClickException("API token cannot be empty")

# Step 3: Test authentication and get workspace info
click.echo("🔄 Validating credentials...")
api_config = Configuration(
access_token=token, host=selected_region.url, ssl_ca_cert=certifi.where()
)

try:
async with Workato(configuration=api_config) as workato_api_client:
user_info = await workato_api_client.users_api.get_workspace_details()
except Exception as e:
raise click.ClickException(f"Authentication failed: {e}") from e

# Step 4: Create profile data
if not selected_region.url:
raise click.ClickException("Region URL is required")

profile_data = ProfileData(
region=selected_region.region,
region_url=selected_region.url,
workspace_id=user_info.id,
)

click.echo(f"✅ Authenticated as: {user_info.name}")

return profile_data, token
161 changes: 160 additions & 1 deletion tests/unit/commands/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

from collections.abc import Callable
from pathlib import Path
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch

import asyncclick as click
import pytest

from workato_platform_cli.cli.commands.profiles import (
create,
delete,
list_profiles,
show,
Expand Down Expand Up @@ -953,3 +955,160 @@ async def test_status_json_exception_handling(
parsed = json.loads(output)

assert parsed["project"]["configured"] is False


@pytest.mark.asyncio
async def test_create_profile_success(
capsys: pytest.CaptureFixture[str],
make_config_manager: Callable[..., Mock],
profile_data_factory: Callable[..., ProfileData],
) -> None:
"""Test successful profile creation."""
# Mock profile data returned from create_profile_interactive
profile_data = profile_data_factory(
region="us",
region_url="https://www.workato.com",
workspace_id=123,
)

config_manager = make_config_manager(
get_profile=Mock(return_value=None), # Profile doesn't exist yet
set_profile=Mock(),
set_current_profile=Mock(),
)

# Mock the create_profile_interactive method
config_manager.profile_manager.create_profile_interactive = AsyncMock(
return_value=(profile_data, "test_token")
)

assert create.callback
await create.callback(profile_name="new_profile", config_manager=config_manager)

output = capsys.readouterr().out
assert "✅ Profile 'new_profile' created successfully" in output
assert "✅ Set 'new_profile' as the active profile" in output

# Verify profile was set and made current
config_manager.profile_manager.set_profile.assert_called_once()
config_manager.profile_manager.set_current_profile.assert_called_once_with(
"new_profile"
)


@pytest.mark.asyncio
async def test_create_profile_already_exists(
capsys: pytest.CaptureFixture[str],
profile_data_factory: Callable[..., ProfileData],
make_config_manager: Callable[..., Mock],
) -> None:
"""Test creating a profile that already exists."""
profile = profile_data_factory()
config_manager = make_config_manager(
get_profile=Mock(return_value=profile), # Profile already exists
)

assert create.callback
await create.callback(profile_name="existing", config_manager=config_manager)

output = capsys.readouterr().out
assert "❌ Profile 'existing' already exists" in output
assert "Use 'workato profiles use' to switch to it" in output


@pytest.mark.asyncio
async def test_create_profile_cancelled_region_selection(
capsys: pytest.CaptureFixture[str],
make_config_manager: Callable[..., Mock],
) -> None:
"""Test profile creation when region selection is cancelled."""
config_manager = make_config_manager(
get_profile=Mock(return_value=None),
)

# Mock create_profile_interactive to raise ClickException (cancelled)
config_manager.profile_manager.create_profile_interactive = AsyncMock(
side_effect=click.ClickException("Setup cancelled")
)

assert create.callback
await create.callback(profile_name="new_profile", config_manager=config_manager)

output = capsys.readouterr().out
assert "❌ Profile creation cancelled" in output


@pytest.mark.asyncio
async def test_create_profile_empty_token(
capsys: pytest.CaptureFixture[str],
make_config_manager: Callable[..., Mock],
) -> None:
"""Test profile creation with empty token."""
config_manager = make_config_manager(
get_profile=Mock(return_value=None),
)

# Mock create_profile_interactive to raise ClickException (empty token)
config_manager.profile_manager.create_profile_interactive = AsyncMock(
side_effect=click.ClickException("API token cannot be empty")
)

assert create.callback
await create.callback(profile_name="new_profile", config_manager=config_manager)

output = capsys.readouterr().out
assert "❌ Profile creation cancelled" in output


@pytest.mark.asyncio
async def test_create_profile_authentication_failure(
capsys: pytest.CaptureFixture[str],
make_config_manager: Callable[..., Mock],
) -> None:
"""Test profile creation when authentication fails."""
config_manager = make_config_manager(
get_profile=Mock(return_value=None),
)

# Mock create_profile_interactive to raise ClickException (auth failed)
config_manager.profile_manager.create_profile_interactive = AsyncMock(
side_effect=click.ClickException("Authentication failed: Invalid credentials")
)

assert create.callback
await create.callback(profile_name="new_profile", config_manager=config_manager)

output = capsys.readouterr().out
assert "❌ Profile creation cancelled" in output


@pytest.mark.asyncio
async def test_create_profile_keyring_failure(
capsys: pytest.CaptureFixture[str],
make_config_manager: Callable[..., Mock],
profile_data_factory: Callable[..., ProfileData],
) -> None:
"""Test profile creation when keyring storage fails."""
# Mock profile data returned from create_profile_interactive
profile_data = profile_data_factory(
region="us",
region_url="https://www.workato.com",
workspace_id=123,
)

config_manager = make_config_manager(
get_profile=Mock(return_value=None),
set_profile=Mock(side_effect=ValueError("Failed to store token in keyring")),
)

# Mock create_profile_interactive to succeed, but set_profile will fail
config_manager.profile_manager.create_profile_interactive = AsyncMock(
return_value=(profile_data, "test_token")
)

assert create.callback
await create.callback(profile_name="new_profile", config_manager=config_manager)

output = capsys.readouterr().out
assert "❌ Failed to save profile:" in output
assert "Failed to store token in keyring" in output
Loading