From 5e2e3fad9e1afd7221ebf0565fc8d0b312181164 Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Wed, 6 May 2026 12:59:37 -0700 Subject: [PATCH] fix(init): prefer linking existing project before create (2.4.9) Update cf init to present existing platform projects and default to linking, while still allowing explicit new project creation with confirmation. Also harden shuttle sorting against null tapeout dates and add regression tests for the new init prompts. Co-authored-by: Cursor --- chipfoundry_cli/main.py | 93 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_init_command.py | 52 ++++++++++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) 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'])