Skip to content
Merged
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
93 changes: 92 additions & 1 deletion chipfoundry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down Expand Up @@ -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', '')
Expand All @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <marwan.abbas@chipfoundry.io>"]
readme = "README.md"
Expand Down
52 changes: 51 additions & 1 deletion tests/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'])
Loading