diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 09cdd8f..662c646 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -389,6 +389,59 @@ def _prompt_with_default(label: str, current: Optional[str], detected: Optional[ return raw +def _shuttle_sort_key(shuttle: dict) -> str: + """Sort shuttles by date while handling null/missing dates safely.""" + tapeout_date = shuttle.get("tapeout_date") + if isinstance(tapeout_date, str) and tapeout_date.strip(): + return tapeout_date + return "9999-12-31" + + +def _confirm_new_project_creation() -> bool: + """Ask for explicit confirmation before creating a new platform project.""" + return click.confirm( + "Create a NEW platform project now? " + "(Select 'No' if you intended to link an existing project with `cf link`.)", + default=False, + ) + + +def _prompt_init_platform_action() -> str: + """Ask whether init should link to an existing project or create a new one.""" + console.print("\n[bold]Platform action[/bold]") + console.print(" [cyan]1[/cyan]. Link to an existing platform project") + console.print(" [cyan]2[/cyan]. Create a new platform project") + choice = console.input("Select option [1/2, default 1]: ").strip() + if choice in ("", "1"): + return "link" + if choice == "2": + return "create" + console.print("[yellow]Invalid selection — defaulting to linking an existing project.[/yellow]") + return "link" + + +def _choose_platform_project(projects: List[dict]) -> Optional[dict]: + """Show a numbered project list and return the selected project, if any.""" + console.print("\n[bold]Your platform projects:[/bold]") + for i, p in enumerate(projects, 1): + status_str = p.get('status', 'unknown') + shuttle_str = f" — {p.get('shuttle_name', '')}" if p.get('shuttle_name') else "" + console.print(f" [cyan]{i}[/cyan]. {p['name']}{shuttle_str} [{status_str}]") + console.print(f" [cyan]{len(projects) + 1}[/cyan]. Create a new platform project") + + choice = console.input("\nSelect project number: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(projects): + return projects[idx] + if idx == len(projects): + return None + except ValueError: + pass + console.print("[red]Invalid selection.[/red]") + return None + + @main.command('init') @click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Project directory (defaults to current directory).') @click.option('--shuttle', default=None, help='Shuttle name or ID to associate with the project.') @@ -539,12 +592,43 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str] console.print(f" Portal: {portal_url}/projects/{platform_id}") return + if api_key: + try: + projects = _api_get("/projects/me") + except SystemExit: + projects = [] + if projects: + action = _prompt_init_platform_action() + if action == "link": + selected = _choose_platform_project(projects) + if selected: + proj['platform_project_id'] = selected['id'] + if selected.get('name'): + old_name = proj.get('name') + proj['name'] = selected['name'] + if old_name and old_name != selected['name']: + console.print( + f"[yellow]Updated project name: '{old_name}' → '{selected['name']}' " + "(synced from platform)[/yellow]" + ) + with open(project_json_path, 'w') as f: + json.dump(data, f, indent=2) + portal_url = _get_portal_url() + console.print(f"\n[green]✓ Linked to existing platform project[/green]") + console.print(f" Name: {selected['name']}") + console.print(f" ID: {selected['id']}") + if github_repo_url: + console.print(f" GitHub: {github_repo_url}") + console.print(f" Portal: {portal_url}/projects/{selected['id']}") + return + console.print("[dim]Continuing with new project creation.[/dim]") + shuttle_id = shuttle if not shuttle_id: try: shuttles = _api_get("/shuttles/available") if shuttles: - shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31')) + shuttles.sort(key=_shuttle_sort_key) console.print("\n[bold]Available shuttles:[/bold]") for i, s in enumerate(shuttles, 1): deadline = s.get('tapeout_date', '') @@ -571,6 +655,13 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str] if github_repo_url: create_data["github_repo_url"] = github_repo_url + if not _confirm_new_project_creation(): + with open(project_json_path, 'w') as f: + json.dump(data, f, indent=2) + console.print("[yellow]Skipped platform project creation.[/yellow]") + console.print("[dim]Tip: Run [bold]cf link[/bold] to select an existing platform project.[/dim]") + return + try: project_resp = _api_post("/projects", create_data) new_id = project_resp.get('id') diff --git a/pyproject.toml b/pyproject.toml index 80dd673..988506d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.4.6" +version = "2.4.9" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" diff --git a/tests/test_init_command.py b/tests/test_init_command.py index 91bfe91..3b6ec47 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -3,7 +3,13 @@ """ import pytest from click.testing import CliRunner -from chipfoundry_cli.main import main +from chipfoundry_cli.main import ( + main, + _shuttle_sort_key, + _confirm_new_project_creation, + _prompt_init_platform_action, + _choose_platform_project, +) from pathlib import Path import json import tempfile @@ -66,6 +72,50 @@ def test_init_defaults_to_current_directory(self, temp_project_dir): assert result.exit_code == 0 + def test_shuttle_sort_key_handles_none_tapeout_date(self): + """Shuttle sort key should not crash on null or missing dates.""" + shuttles = [ + {"id": "late", "tapeout_date": None}, + {"id": "soon", "tapeout_date": "2026-06-01"}, + {"id": "missing"}, + {"id": "middle", "tapeout_date": "2026-07-15"}, + ] + + sorted_ids = [s["id"] for s in sorted(shuttles, key=_shuttle_sort_key)] + assert sorted_ids == ["soon", "middle", "late", "missing"] + + def test_confirm_new_project_creation_uses_safe_default(self, monkeypatch): + """Creation confirmation should default to 'No' to prevent accidental project creation.""" + captured = {} + + def fake_confirm(text, default): + captured["text"] = text + captured["default"] = default + return True + + monkeypatch.setattr("chipfoundry_cli.main.click.confirm", fake_confirm) + approved = _confirm_new_project_creation() + + assert approved is True + assert captured["default"] is False + assert "Create a NEW platform project now?" in captured["text"] + + def test_prompt_init_platform_action_defaults_to_link(self, monkeypatch): + """init should default to linking an existing project.""" + monkeypatch.setattr("chipfoundry_cli.main.console.input", lambda _msg: "") + action = _prompt_init_platform_action() + assert action == "link" + + def test_choose_platform_project_returns_selected_project(self, monkeypatch): + """Chooser should return selected project entry.""" + projects = [ + {"id": "p1", "name": "Project 1", "status": "draft"}, + {"id": "p2", "name": "Project 2", "status": "submitted"}, + ] + monkeypatch.setattr("chipfoundry_cli.main.console.input", lambda _msg: "2") + selected = _choose_platform_project(projects) + assert selected == projects[1] + if __name__ == '__main__': pytest.main([__file__, '-v'])