Skip to content

Commit 688fb21

Browse files
authored
Merge pull request #23 from chipfoundry/fix/init-link-or-create-flow
fix(init): prefer linking existing project before create (2.4.9)
2 parents 7577b6f + 5e2e3fa commit 688fb21

3 files changed

Lines changed: 144 additions & 3 deletions

File tree

chipfoundry_cli/main.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,59 @@ def _prompt_with_default(label: str, current: Optional[str], detected: Optional[
389389
return raw
390390

391391

392+
def _shuttle_sort_key(shuttle: dict) -> str:
393+
"""Sort shuttles by date while handling null/missing dates safely."""
394+
tapeout_date = shuttle.get("tapeout_date")
395+
if isinstance(tapeout_date, str) and tapeout_date.strip():
396+
return tapeout_date
397+
return "9999-12-31"
398+
399+
400+
def _confirm_new_project_creation() -> bool:
401+
"""Ask for explicit confirmation before creating a new platform project."""
402+
return click.confirm(
403+
"Create a NEW platform project now? "
404+
"(Select 'No' if you intended to link an existing project with `cf link`.)",
405+
default=False,
406+
)
407+
408+
409+
def _prompt_init_platform_action() -> str:
410+
"""Ask whether init should link to an existing project or create a new one."""
411+
console.print("\n[bold]Platform action[/bold]")
412+
console.print(" [cyan]1[/cyan]. Link to an existing platform project")
413+
console.print(" [cyan]2[/cyan]. Create a new platform project")
414+
choice = console.input("Select option [1/2, default 1]: ").strip()
415+
if choice in ("", "1"):
416+
return "link"
417+
if choice == "2":
418+
return "create"
419+
console.print("[yellow]Invalid selection — defaulting to linking an existing project.[/yellow]")
420+
return "link"
421+
422+
423+
def _choose_platform_project(projects: List[dict]) -> Optional[dict]:
424+
"""Show a numbered project list and return the selected project, if any."""
425+
console.print("\n[bold]Your platform projects:[/bold]")
426+
for i, p in enumerate(projects, 1):
427+
status_str = p.get('status', 'unknown')
428+
shuttle_str = f" — {p.get('shuttle_name', '')}" if p.get('shuttle_name') else ""
429+
console.print(f" [cyan]{i}[/cyan]. {p['name']}{shuttle_str} [{status_str}]")
430+
console.print(f" [cyan]{len(projects) + 1}[/cyan]. Create a new platform project")
431+
432+
choice = console.input("\nSelect project number: ").strip()
433+
try:
434+
idx = int(choice) - 1
435+
if 0 <= idx < len(projects):
436+
return projects[idx]
437+
if idx == len(projects):
438+
return None
439+
except ValueError:
440+
pass
441+
console.print("[red]Invalid selection.[/red]")
442+
return None
443+
444+
392445
@main.command('init')
393446
@click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Project directory (defaults to current directory).')
394447
@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]
539592
console.print(f" Portal: {portal_url}/projects/{platform_id}")
540593
return
541594

595+
if api_key:
596+
try:
597+
projects = _api_get("/projects/me")
598+
except SystemExit:
599+
projects = []
600+
if projects:
601+
action = _prompt_init_platform_action()
602+
if action == "link":
603+
selected = _choose_platform_project(projects)
604+
if selected:
605+
proj['platform_project_id'] = selected['id']
606+
if selected.get('name'):
607+
old_name = proj.get('name')
608+
proj['name'] = selected['name']
609+
if old_name and old_name != selected['name']:
610+
console.print(
611+
f"[yellow]Updated project name: '{old_name}' → '{selected['name']}' "
612+
"(synced from platform)[/yellow]"
613+
)
614+
with open(project_json_path, 'w') as f:
615+
json.dump(data, f, indent=2)
616+
portal_url = _get_portal_url()
617+
console.print(f"\n[green]✓ Linked to existing platform project[/green]")
618+
console.print(f" Name: {selected['name']}")
619+
console.print(f" ID: {selected['id']}")
620+
if github_repo_url:
621+
console.print(f" GitHub: {github_repo_url}")
622+
console.print(f" Portal: {portal_url}/projects/{selected['id']}")
623+
return
624+
console.print("[dim]Continuing with new project creation.[/dim]")
625+
542626
shuttle_id = shuttle
543627
if not shuttle_id:
544628
try:
545629
shuttles = _api_get("/shuttles/available")
546630
if shuttles:
547-
shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31'))
631+
shuttles.sort(key=_shuttle_sort_key)
548632
console.print("\n[bold]Available shuttles:[/bold]")
549633
for i, s in enumerate(shuttles, 1):
550634
deadline = s.get('tapeout_date', '')
@@ -571,6 +655,13 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str]
571655
if github_repo_url:
572656
create_data["github_repo_url"] = github_repo_url
573657

658+
if not _confirm_new_project_creation():
659+
with open(project_json_path, 'w') as f:
660+
json.dump(data, f, indent=2)
661+
console.print("[yellow]Skipped platform project creation.[/yellow]")
662+
console.print("[dim]Tip: Run [bold]cf link[/bold] to select an existing platform project.[/dim]")
663+
return
664+
574665
try:
575666
project_resp = _api_post("/projects", create_data)
576667
new_id = project_resp.get('id')

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "chipfoundry-cli"
3-
version = "2.4.6"
3+
version = "2.4.9"
44
description = "CLI tool to automate ChipFoundry project submission to SFTP server"
55
authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
66
readme = "README.md"

tests/test_init_command.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
"""
44
import pytest
55
from click.testing import CliRunner
6-
from chipfoundry_cli.main import main
6+
from chipfoundry_cli.main import (
7+
main,
8+
_shuttle_sort_key,
9+
_confirm_new_project_creation,
10+
_prompt_init_platform_action,
11+
_choose_platform_project,
12+
)
713
from pathlib import Path
814
import json
915
import tempfile
@@ -66,6 +72,50 @@ def test_init_defaults_to_current_directory(self, temp_project_dir):
6672

6773
assert result.exit_code == 0
6874

75+
def test_shuttle_sort_key_handles_none_tapeout_date(self):
76+
"""Shuttle sort key should not crash on null or missing dates."""
77+
shuttles = [
78+
{"id": "late", "tapeout_date": None},
79+
{"id": "soon", "tapeout_date": "2026-06-01"},
80+
{"id": "missing"},
81+
{"id": "middle", "tapeout_date": "2026-07-15"},
82+
]
83+
84+
sorted_ids = [s["id"] for s in sorted(shuttles, key=_shuttle_sort_key)]
85+
assert sorted_ids == ["soon", "middle", "late", "missing"]
86+
87+
def test_confirm_new_project_creation_uses_safe_default(self, monkeypatch):
88+
"""Creation confirmation should default to 'No' to prevent accidental project creation."""
89+
captured = {}
90+
91+
def fake_confirm(text, default):
92+
captured["text"] = text
93+
captured["default"] = default
94+
return True
95+
96+
monkeypatch.setattr("chipfoundry_cli.main.click.confirm", fake_confirm)
97+
approved = _confirm_new_project_creation()
98+
99+
assert approved is True
100+
assert captured["default"] is False
101+
assert "Create a NEW platform project now?" in captured["text"]
102+
103+
def test_prompt_init_platform_action_defaults_to_link(self, monkeypatch):
104+
"""init should default to linking an existing project."""
105+
monkeypatch.setattr("chipfoundry_cli.main.console.input", lambda _msg: "")
106+
action = _prompt_init_platform_action()
107+
assert action == "link"
108+
109+
def test_choose_platform_project_returns_selected_project(self, monkeypatch):
110+
"""Chooser should return selected project entry."""
111+
projects = [
112+
{"id": "p1", "name": "Project 1", "status": "draft"},
113+
{"id": "p2", "name": "Project 2", "status": "submitted"},
114+
]
115+
monkeypatch.setattr("chipfoundry_cli.main.console.input", lambda _msg: "2")
116+
selected = _choose_platform_project(projects)
117+
assert selected == projects[1]
118+
69119

70120
if __name__ == '__main__':
71121
pytest.main([__file__, '-v'])

0 commit comments

Comments
 (0)