Skip to content

Commit b3e3e8b

Browse files
shihanpanclaude
andcommitted
Add profiles rename command to allow renaming profile names (#62)
* Add profiles rename command to allow renaming profile names Implement workato profiles rename command with positional arguments and confirmation prompt. The command: - Takes old_name and new_name as positional arguments - Shows confirmation prompt before renaming - Handles token migration from old to new profile via keyring - Updates current profile if renaming the current profile - Provides clear error messages for edge cases Added comprehensive test coverage for: - Successful rename - Renaming current profile - Profile not found - New name already exists - User cancellation - Keyring failure handling Fixes #113 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * added workatoenv update when profile is renamed * added output mode json --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent b40a61d commit b3e3e8b

File tree

3 files changed

+617
-23
lines changed

3 files changed

+617
-23
lines changed

src/workato_platform_cli/cli/commands/profiles.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import os
55

6+
from pathlib import Path
67
from typing import Any
78

89
import asyncclick as click
@@ -345,6 +346,164 @@ async def status(
345346
click.echo(" 💡 Or set WORKATO_API_TOKEN environment variable")
346347

347348

349+
def _format_file_path(file_path: Path) -> str:
350+
"""Format file path for display, showing relative to current directory."""
351+
try:
352+
rel_path = file_path.relative_to(Path.cwd())
353+
return f"./{rel_path}"
354+
except ValueError:
355+
# File is outside current directory (shouldn't happen with cwd search)
356+
return str(file_path)
357+
358+
359+
def _update_workatoenv_files(old_name: str, new_name: str) -> list[Path]:
360+
"""Find and update all .workatoenv files that reference the old profile name.
361+
362+
Searches recursively from current directory.
363+
Returns list of updated file paths.
364+
"""
365+
updated_files = []
366+
current_dir = Path.cwd()
367+
368+
# Search from current directory for .workatoenv files
369+
for workatoenv_file in current_dir.rglob(".workatoenv"):
370+
try:
371+
# Open for reading and writing
372+
with open(workatoenv_file, "r+") as f:
373+
data = json.load(f)
374+
375+
# Check if profile field matches old name
376+
if data.get("profile") == old_name:
377+
# Update to new name
378+
data["profile"] = new_name
379+
380+
# Write back (truncate and write from beginning)
381+
f.seek(0)
382+
f.truncate()
383+
json.dump(data, f, indent=2)
384+
f.write("\n") # Add trailing newline
385+
386+
updated_files.append(workatoenv_file)
387+
except (OSError, json.JSONDecodeError):
388+
# Skip files we can't read or parse
389+
continue
390+
391+
return updated_files
392+
393+
394+
@profiles.command()
395+
@click.argument("old_name")
396+
@click.argument("new_name")
397+
@click.option(
398+
"--output-mode",
399+
type=click.Choice(["table", "json"]),
400+
default="table",
401+
help="Output format: table (default) or json",
402+
)
403+
@click.option(
404+
"--yes",
405+
is_flag=True,
406+
help="Skip confirmation prompt",
407+
)
408+
@handle_cli_exceptions
409+
@inject
410+
async def rename(
411+
old_name: str,
412+
new_name: str,
413+
output_mode: str = "table",
414+
yes: bool = False,
415+
config_manager: ConfigManager = Provide[Container.config_manager],
416+
) -> None:
417+
"""Rename a profile"""
418+
# Check if old profile exists
419+
old_profile = config_manager.profile_manager.get_profile(old_name)
420+
if not old_profile:
421+
if output_mode == "json":
422+
error_msg = f"Profile '{old_name}' not found"
423+
output_data: dict[str, Any] = {"status": "error", "error": error_msg}
424+
click.echo(json.dumps(output_data))
425+
else:
426+
click.echo(f"❌ Profile '{old_name}' not found")
427+
click.echo("💡 Use 'workato profiles list' to see available profiles")
428+
return
429+
430+
# Check if new name already exists
431+
if config_manager.profile_manager.get_profile(new_name):
432+
if output_mode == "json":
433+
error_msg = f"Profile '{new_name}' already exists"
434+
output_data = {"status": "error", "error": error_msg}
435+
click.echo(json.dumps(output_data))
436+
else:
437+
click.echo(f"❌ Profile '{new_name}' already exists")
438+
click.echo(
439+
"💡 Choose a different name or delete the existing profile first"
440+
)
441+
return
442+
443+
# Show confirmation prompt (skip in JSON mode or if --yes flag)
444+
if (
445+
not yes
446+
and output_mode != "json"
447+
and not click.confirm(f"Rename profile '{old_name}' to '{new_name}'?")
448+
):
449+
click.echo("❌ Rename cancelled")
450+
return
451+
452+
# Get the token from keyring
453+
old_token = config_manager.profile_manager._get_token_from_keyring(old_name)
454+
455+
# Create new profile with same data and token
456+
try:
457+
config_manager.profile_manager.set_profile(new_name, old_profile, old_token)
458+
except ValueError as e:
459+
if output_mode == "json":
460+
output_data = {"status": "error", "error": str(e)}
461+
click.echo(json.dumps(output_data))
462+
else:
463+
click.echo(f"❌ Failed to create new profile: {e}")
464+
return
465+
466+
# If old profile was current, set new profile as current
467+
current_profile = config_manager.profile_manager.get_current_profile_name()
468+
was_current = current_profile == old_name
469+
if was_current:
470+
config_manager.profile_manager.set_current_profile(new_name)
471+
472+
# Delete old profile
473+
config_manager.profile_manager.delete_profile(old_name)
474+
475+
# Update all .workatoenv files that reference the old profile
476+
if output_mode == "table":
477+
click.echo("🔄 Updating project configurations...")
478+
updated_files = _update_workatoenv_files(old_name, new_name)
479+
480+
# JSON output mode
481+
if output_mode == "json":
482+
output_data = {
483+
"status": "success",
484+
"old_name": old_name,
485+
"new_name": new_name,
486+
"was_current_profile": was_current,
487+
"updated_files": [str(f) for f in updated_files],
488+
"updated_files_count": len(updated_files),
489+
}
490+
click.echo(json.dumps(output_data))
491+
return
492+
493+
# Table output mode (default)
494+
click.echo("✅ Profile renamed successfully")
495+
if was_current:
496+
click.echo(f"✅ Set '{new_name}' as the active profile")
497+
498+
# Display updated files
499+
if not updated_files:
500+
return
501+
502+
click.echo(f"✅ Updated {len(updated_files)} project configuration(s)")
503+
for file_path in updated_files:
504+
click.echo(f" • {_format_file_path(file_path)}")
505+
506+
348507
@profiles.command()
349508
@click.argument("profile_name")
350509
@click.confirmation_option(prompt="Are you sure you want to delete this profile?")

tests/conftest.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Pytest configuration and shared fixtures."""
22

3+
import json
34
import tempfile
45

5-
from collections.abc import Generator
6+
from collections.abc import Callable, Generator
67
from pathlib import Path
78
from typing import Any
89
from unittest.mock import MagicMock, Mock, patch
@@ -144,3 +145,80 @@ def prevent_keyring_errors() -> None:
144145
minimal_keyring.delete_password.return_value = None
145146

146147
sys.modules["keyring"] = minimal_keyring
148+
149+
150+
# Shared test helpers
151+
152+
153+
def parse_json_output(capsys: pytest.CaptureFixture[str]) -> dict[str, Any]:
154+
"""Parse JSON output from capsys."""
155+
output = capsys.readouterr().out
156+
result: dict[str, Any] = json.loads(output)
157+
return result
158+
159+
160+
def create_workatoenv_file(
161+
tmp_path: Path,
162+
dir_name: str,
163+
profile: str,
164+
project_id: int = 123,
165+
**extra_fields: Any,
166+
) -> Path:
167+
"""Create a test .workatoenv file.
168+
169+
Args:
170+
tmp_path: Temporary directory path
171+
dir_name: Name of the project directory to create
172+
profile: Profile name to set in the workatoenv file
173+
project_id: Project ID (default: 123)
174+
**extra_fields: Additional fields to include in the workatoenv file
175+
"""
176+
project_dir = tmp_path / dir_name
177+
project_dir.mkdir()
178+
workatoenv = project_dir / ".workatoenv"
179+
180+
data = {"project_id": project_id, "profile": profile}
181+
data.update(extra_fields)
182+
183+
workatoenv.write_text(json.dumps(data))
184+
return workatoenv
185+
186+
187+
def _make_workatoenv_updater(tmp_path: Path) -> Callable[[str, str], list[Path]]:
188+
"""Create a mock _update_workatoenv_files function for testing.
189+
190+
Returns a function that searches tmp_path instead of home directory.
191+
"""
192+
193+
def mock_update(old_name: str, new_name: str) -> list[Path]:
194+
updated_files = []
195+
for workatoenv_file in tmp_path.rglob(".workatoenv"):
196+
try:
197+
with open(workatoenv_file, "r+") as f:
198+
data = json.load(f)
199+
if data.get("profile") == old_name:
200+
data["profile"] = new_name
201+
f.seek(0)
202+
f.truncate()
203+
json.dump(data, f, indent=2)
204+
f.write("\n")
205+
updated_files.append(workatoenv_file)
206+
except (OSError, json.JSONDecodeError):
207+
continue
208+
return updated_files
209+
210+
return mock_update
211+
212+
213+
def mock_workatoenv_updates(tmp_path: Path) -> Any:
214+
"""Context manager for mocking workatoenv file updates.
215+
216+
Use this to mock the _update_workatoenv_files function in profiles module.
217+
Must import profiles_module in your test file to use this helper.
218+
"""
219+
from workato_platform_cli.cli.commands import profiles as profiles_module
220+
221+
mock_update = _make_workatoenv_updater(tmp_path)
222+
return patch.object(
223+
profiles_module, "_update_workatoenv_files", side_effect=mock_update
224+
)

0 commit comments

Comments
 (0)