From 78eea92424edb2213a8bf7c384fa40ded7aa2631 Mon Sep 17 00:00:00 2001 From: jeffdi Date: Fri, 17 Apr 2026 11:39:56 -0700 Subject: [PATCH 1/6] feat: add cf push --remote and make cf init idempotent Adds an HTTPS-only alternative to the SFTP push path for users behind corporate firewalls that block port 22, and reworks cf init to be safe to re-run on linked projects. cf push --remote: - Verifies local HEAD is reachable from a remote ref on origin and that the push-critical files (wrapper GDS, verilog/rtl/user_defines.v when not openframe, .cf/project.json when tracked) are clean at HEAD. - Calls POST /projects/{id}/remote-push so the platform fetches those files via the ChipFoundry GitHub App and stages them into the SFTP landing zone. Downstream pipeline is unchanged. - Keeps existing --submit semantics. cf init: - Idempotent refresh: if already linked, fetches the platform project, pre-fills prompts, auto-detects github_repo_url from git remote, and PUTs only diffs. platform_project_id is preserved. - Blank input keeps the current/detected value; typing 'clear' removes a field explicitly. Also extracts shared GDS wrapper constants and detect_github_repo_url / get_head_commit_sha helpers into utils.py, and adds a verify_push_repo helper (and RemotePushGitError) alongside the existing remote-precheck verifier. README updated for the new flows including a corporate-firewall tip. --- README.md | 53 +++- chipfoundry_cli/main.py | 403 +++++++++++++++++++------ chipfoundry_cli/remote_precheck_git.py | 95 ++++++ chipfoundry_cli/utils.py | 61 +++- 4 files changed, 509 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 82de7eb..7122968 100644 --- a/README.md +++ b/README.md @@ -230,14 +230,14 @@ cf logout - Removes your stored API key from the local config -### Initialize a New Project +### Initialize or Refresh a Project ```bash -cf init [--project-root DIRECTORY] +cf init [--project-root DIRECTORY] [--shuttle NAME_OR_ID] [--description TEXT] ``` > [!IMPORTANT] -> This command **must be run first** after cloning a repository. It is required before running: +> Run this first after cloning a repository. It is required before running: > - `cf gpio-config` > - `cf harden` > - `cf precheck` @@ -247,14 +247,15 @@ cf init [--project-root DIRECTORY] > If you skip this step, other commands will show an error directing you to run `cf init` first. **What it does:** -- **Smart defaults**: Auto-detects project name from directory and project type from GDS files -- **Interactive prompts**: Shows detected values in prompts for easy acceptance -- **Shuttle selection**: Prompts to select an available shuttle (sorted by nearest deadline) -- **Platform registration**: Creates the project on the platform and links it automatically -- Creates `.cf/project.json` with project metadata +- **Idempotent refresh**: Running `cf init` again on an already-linked project pulls in the current platform values, pre-fills prompts, and only PUTs the differences you confirm. The `platform_project_id` link is preserved. +- **Smart defaults**: Auto-detects project name from directory, project type from GDS files, and GitHub repo URL from your `origin` remote (HTTPS or SSH). +- **Interactive prompts**: Blank input keeps the current/detected value; type `clear` to explicitly remove a field. +- **Shuttle selection**: On first init, prompts to select an available shuttle (sorted by nearest deadline). +- **Platform registration**: Creates the project on the platform and links it automatically. +- Setting the GitHub repo URL enables `cf precheck --remote` and `cf push --remote`. > [!NOTE] -> GDS hash is generated during `push`, not `init` +> GDS hash is generated during `push`, not `init`. ### Link an Existing Project @@ -564,17 +565,18 @@ cf verify counter_la --dry-run cf push [OPTIONS] ``` -**Prerequisites:** `cf login`, `cf link` (or `cf init`), `cf config` +**Prerequisites:** `cf login`, `cf link` (or `cf init`), `cf config` (SFTP mode only). **Options:** - `--project-root`: Specify project directory -- `--force-overwrite`: Overwrite existing files on SFTP +- `--force-overwrite`: Overwrite existing files on SFTP (SFTP mode only) - `--submit`: Submit the project for review after upload - `--dry-run`: Preview what would be uploaded -- `--sftp-username`: Override configured username -- `--sftp-key`: Override configured key path +- `--sftp-username`: Override configured username (SFTP mode only) +- `--sftp-key`: Override configured key path (SFTP mode only) +- `--remote`: HTTPS-only upload via the ChipFoundry GitHub App (no SFTP). Use this when port 22 is blocked by your corporate firewall. -**What happens:** +**SFTP mode (default):** 1. Verifies the project is linked to the platform and you are logged in 2. Collects required project files 3. Auto-detects project type from GDS file @@ -583,6 +585,29 @@ cf push [OPTIONS] 6. Syncs `project.json` data to the platform (GDS hash, version, project ID, slot number) 7. If `--submit` is used, submits the project for admin review +**Remote (HTTPS) mode — `cf push --remote`:** + +Firewall friendly: only outbound HTTPS is needed. The CLI never uploads file +contents itself; instead, the platform fetches them from your GitHub repo +via the ChipFoundry GitHub App at your local HEAD commit. + +Preconditions: +- Project has a GitHub repo URL (set via `cf init`, shown in the portal). +- The ChipFoundry GitHub App is installed on that repo (prompted in the portal). +- Your local `HEAD` has been pushed to `origin` on some branch (`git push`). +- Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (when not an openframe project), and `.cf/project.json` (when tracked). + +What happens: +1. `cf push --remote` resolves your local HEAD SHA and checks it is reachable from a remote ref. +2. Platform uses its GitHub App installation token to read the three push-critical files at that commit and stages them into your SFTP landing zone. +3. `project.json` is synced to the platform, exactly like an SFTP push. +4. `--submit` submits for review on success. + +> [!TIP] +> If `cf push` fails to reach `sftp.chipfoundry.io:22` from inside a corporate +> network, run `cf push --remote` instead. No VPN required — just outbound +> HTTPS and a GitHub repo linked to the project. + **GDS File Handling:** - **Both compressed (`.gz`) and uncompressed (`.gds`) files are supported** - **No automatic compression** - files are uploaded as-is diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 8028e20..4b75c41 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1,5 +1,6 @@ import click import getpass +from typing import Optional, List from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo from chipfoundry_cli.utils import ( collect_project_files, ensure_cf_directory, update_or_create_project_json, @@ -8,7 +9,8 @@ open_html_in_browser, download_with_progress, update_repo_files, fetch_versions_from_upstream, parse_user_defines_v, update_user_defines_v, get_gpio_config_from_project_json, save_gpio_config_to_project_json, - GPIO_MODES, GPIO_MODE_DESCRIPTIONS, GPIO_HEX_TO_MODE + GPIO_MODES, GPIO_MODE_DESCRIPTIONS, GPIO_HEX_TO_MODE, + detect_github_repo_url, get_head_commit_sha, ) import os from pathlib import Path @@ -306,31 +308,59 @@ def keyview(): print("") _print_manual_key_instructions() +def _prompt_with_default(label: str, current: Optional[str], detected: Optional[str] = None) -> Optional[str]: + """Interactive prompt that preserves `current` on blank input. + + - Enter = keep current (or accept detected when no current). + - Type a value = new value. + - Type 'clear' = explicitly remove the value (returns None). + Returned value is stripped. `clear` is case-insensitive. + """ + effective_default = current if current else detected + hint_parts = [] + if current: + hint_parts.append(f"current: [cyan]{current}[/cyan]") + if detected and detected != current: + hint_parts.append(f"detected: [cyan]{detected}[/cyan]") + hint_parts.append("blank=keep, 'clear'=remove") + hint = ", ".join(hint_parts) + raw = console.input(f"{label} ({hint}): ").strip() + if raw == "": + return effective_default + if raw.lower() == "clear": + return None + return raw + + @main.command('init') -@click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Directory to create the project in (defaults to current directory).') +@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.') -@click.option('--description', default=None, help='Project description.') +@click.option('--description', default=None, help='Project description (skips description prompt).') def init(project_root, shuttle, description): - """Initialize a new ChipFoundry project (.cf/project.json) in the given directory.""" + """Initialize or refresh the local ChipFoundry project configuration. + + Running `cf init` is idempotent: if the project is already linked to the + platform, existing values are pulled in, auto-detected values from the + workspace (e.g. GitHub remote) are offered, and only the changes you + confirm are pushed back via PUT. The `platform_project_id` link is + preserved — use `cf unlink` to disconnect. + """ if not project_root: project_root = os.getcwd() + project_root = str(Path(project_root).resolve()) cf_dir = Path(project_root) / '.cf' cf_dir.mkdir(parents=True, exist_ok=True) project_json_path = cf_dir / 'project.json' - existing_platform_id = None + local_data: dict = {} if project_json_path.exists(): - with open(project_json_path) as f: - existing_data = json.load(f) - existing_platform_id = existing_data.get('project', {}).get('platform_project_id') - if existing_platform_id: - console.print(f"[yellow]This project is already linked to platform project {existing_platform_id}.[/yellow]") - console.print("Use [bold]cf status[/bold] to check it or [bold]cf unlink[/bold] to disconnect.") - return - overwrite = console.input(f"[yellow]project.json already exists at {project_json_path}. Overwrite? (y/N): [/yellow]").strip().lower() - if overwrite != 'y': - console.print("[red]Aborted project initialization.[/red]") - return + try: + with open(project_json_path) as f: + local_data = json.load(f) + except (OSError, json.JSONDecodeError) as e: + console.print(f"[red]✗ Could not read existing {project_json_path}: {e}[/red]") + raise click.Abort() + local_proj = local_data.get('project', {}) if isinstance(local_data, dict) else {} config = load_user_config() username = config.get("sftp_username") @@ -347,92 +377,159 @@ def init(project_root, shuttle, description): console.print("[bold red]No SFTP account linked to your platform account. Please run 'cf login' first.[/bold red]") raise click.Abort() + api_key = config.get('api_key') + platform_id = local_proj.get('platform_project_id') + platform_proj: Optional[dict] = None + if platform_id and api_key: + try: + platform_proj = _api_get(f"/projects/{platform_id}") + except SystemExit: + console.print(f"[yellow]Could not fetch linked platform project {platform_id}; continuing with local data only.[/yellow]") + platform_proj = None + + mode = "refresh" if platform_proj else "create" + console.print(f"[bold cyan]cf init[/bold cyan] — {'refreshing linked project' if mode == 'refresh' else 'initializing new project'}") + + def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str]: + """Prefer platform value when linked, else local value.""" + kp = key_platform or key_local + if platform_proj is not None and platform_proj.get(kp) not in (None, ""): + return platform_proj.get(kp) + val = local_proj.get(key_local) + return val if val not in (None, "") else None + + current_name = _merged('name') + default_name = current_name or Path(project_root).name + detected_type = None gds_dir = Path(project_root) / 'gds' - gds_type = None for gds_name, gtype in GDS_TYPE_MAP.items(): if (gds_dir / gds_name).exists(): - gds_type = gtype + detected_type = gtype break + current_type = local_proj.get('type') or (platform_proj or {}).get('design_type') + current_desc = _merged('description') + current_github = (platform_proj or {}).get('github_repo_url') if platform_proj else local_proj.get('github_repo_url') + detected_github = detect_github_repo_url(project_root) + + name = _prompt_with_default("Project name", current_name, default_name) or default_name + project_type = _prompt_with_default( + "Project type (digital/analog/openframe)", current_type, detected_type + ) + if not project_type: + console.print("[red]Project type is required.[/red]") + raise click.Abort() + + if description is not None: + description_val: Optional[str] = description or None + else: + description_val = _prompt_with_default("Description", current_desc, None) - default_name = Path(project_root).name - name = console.input(f"Project name (detected: [cyan]{default_name}[/cyan]): ").strip() or default_name + github_repo_url = _prompt_with_default("GitHub repo URL", current_github, detected_github) - if gds_type: - project_type = console.input(f"Project type (digital/analog/openframe) (detected: [cyan]{gds_type}[/cyan]): ").strip() or gds_type + data = local_data if isinstance(local_data, dict) else {} + proj = data.setdefault('project', {}) + proj['name'] = name + proj['type'] = project_type + proj['user'] = username + proj.setdefault('version', local_proj.get('version') or "1") + proj.setdefault('user_project_wrapper_hash', local_proj.get('user_project_wrapper_hash', "")) + proj.setdefault('submission_state', local_proj.get('submission_state', "Draft")) + if github_repo_url: + proj['github_repo_url'] = github_repo_url else: - project_type = console.input("Project type (digital/analog/openframe): ").strip() - - version = "1" - data = { - "project": { - "name": name, - "type": project_type, - "user": username, - "version": version, - "user_project_wrapper_hash": "", - "submission_state": "Draft" - } - } + proj.pop('github_repo_url', None) - api_key = config.get('api_key') - if api_key: - shuttle_id = None - if not shuttle: + if not api_key: + with open(project_json_path, 'w') as f: + json.dump(data, f, indent=2) + console.print(f"[green]✓ Saved local project config at {project_json_path}[/green]") + console.print("[dim]Tip: Run [bold]cf login[/bold] to connect this project to the platform.[/dim]") + return + + if platform_proj: + update_payload: dict = {} + if name != platform_proj.get('name'): + update_payload['name'] = name + if description_val != (platform_proj.get('description') or None): + update_payload['description'] = description_val or "" + if project_type != platform_proj.get('design_type'): + update_payload['design_type'] = project_type + if (github_repo_url or None) != (platform_proj.get('github_repo_url') or None): + update_payload['github_repo_url'] = github_repo_url or "" + + if update_payload: try: - shuttles = _api_get("/shuttles/available") - if shuttles: - shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31')) - console.print("\n[bold]Available shuttles:[/bold]") - for i, s in enumerate(shuttles, 1): - deadline = s.get('tapeout_date', '') - console.print(f" [cyan]{i}[/cyan]. {s['name']}{f' — submission deadline {deadline}' if deadline else ''}") - console.print(f" [cyan]{len(shuttles) + 1}[/cyan]. Skip — choose later") - choice = console.input("\nSelect shuttle: ").strip() - try: - idx = int(choice) - 1 - if 0 <= idx < len(shuttles): - shuttle_id = shuttles[idx]['id'] - except (ValueError, IndexError): - pass + updated = _api_put(f"/projects/{platform_id}", update_payload) + platform_proj = updated + console.print(f"[green]✓ Updated platform project[/green] ({', '.join(update_payload.keys())})") except SystemExit: - console.print("[dim]Could not fetch shuttles — continuing without shuttle selection.[/dim]") + console.print("[yellow]Platform update failed — local changes saved.[/yellow]") else: - shuttle_id = shuttle + console.print("[dim]No platform changes needed.[/dim]") - create_data = { - "name": name, - "description": description or "", - "design_type": project_type, - "registration_source": "cli", - } - if shuttle_id: - create_data["shuttle_id"] = str(shuttle_id) + proj['platform_project_id'] = platform_id + with open(project_json_path, 'w') as f: + json.dump(data, f, indent=2) + portal_url = _get_portal_url() + console.print(f" Name: {name}") + console.print(f" ID: {platform_id}") + if github_repo_url: + console.print(f" GitHub: {github_repo_url}") + console.print(f" Portal: {portal_url}/projects/{platform_id}") + return + shuttle_id = shuttle + if not shuttle_id: try: - project_resp = _api_post("/projects", create_data) - platform_id = project_resp.get('id') - data['project']['platform_project_id'] = platform_id - - with open(project_json_path, 'w') as f: - json.dump(data, f, indent=2) - - portal_url = _get_portal_url() - console.print(f"\n[green]✓ Project created on platform[/green]") - console.print(f" Name: {name}") - console.print(f" ID: {platform_id}") - if project_resp.get('shuttle_name'): - console.print(f" Shuttle: {project_resp['shuttle_name']}") - console.print(f" Status: Draft") - console.print(f" Portal: {portal_url}/projects/{platform_id}") - return + shuttles = _api_get("/shuttles/available") + if shuttles: + shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31')) + console.print("\n[bold]Available shuttles:[/bold]") + for i, s in enumerate(shuttles, 1): + deadline = s.get('tapeout_date', '') + console.print(f" [cyan]{i}[/cyan]. {s['name']}{f' — submission deadline {deadline}' if deadline else ''}") + console.print(f" [cyan]{len(shuttles) + 1}[/cyan]. Skip — choose later") + choice = console.input("\nSelect shuttle: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(shuttles): + shuttle_id = shuttles[idx]['id'] + except (ValueError, IndexError): + pass except SystemExit: - console.print("[yellow]Platform project creation failed — saving local project only.[/yellow]") - else: - console.print("[dim]Tip: Run [bold]cf login[/bold] to connect this project to the platform.[/dim]") + console.print("[dim]Could not fetch shuttles — continuing without shuttle selection.[/dim]") - with open(project_json_path, 'w') as f: - json.dump(data, f, indent=2) - console.print(f"[green]✓ Initialized project at {project_json_path}[/green]") + create_data: dict = { + "name": name, + "description": description_val or "", + "design_type": project_type, + "registration_source": "cli", + } + if shuttle_id: + create_data["shuttle_id"] = str(shuttle_id) + if github_repo_url: + create_data["github_repo_url"] = github_repo_url + + try: + project_resp = _api_post("/projects", create_data) + new_id = project_resp.get('id') + proj['platform_project_id'] = new_id + with open(project_json_path, 'w') as f: + json.dump(data, f, indent=2) + portal_url = _get_portal_url() + console.print(f"\n[green]✓ Project created on platform[/green]") + console.print(f" Name: {name}") + console.print(f" ID: {new_id}") + if project_resp.get('shuttle_name'): + console.print(f" Shuttle: {project_resp['shuttle_name']}") + if github_repo_url: + console.print(f" GitHub: {github_repo_url}") + console.print(f" Status: Draft") + console.print(f" Portal: {portal_url}/projects/{new_id}") + except SystemExit: + console.print("[yellow]Platform project creation failed — saving local project only.[/yellow]") + with open(project_json_path, 'w') as f: + json.dump(data, f, indent=2) @main.command('gpio-config') @click.option('--project-root', required=False, type=click.Path(exists=True, file_okay=False), help='Path to the project directory (defaults to current directory).') @@ -1296,6 +1393,127 @@ def action_quit(self) -> None: console.print(f"[red]Error updating user_defines.v: {e}[/red]") +def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_run: bool, submit: bool) -> None: + """Push project files to the platform via the ChipFoundry GitHub App (HTTPS only). + + Preconditions enforced here: + - Project is linked (`platform_project_id` in .cf/project.json). + - Logged in (api key). + - Local git HEAD is reachable from a remote ref on origin and the files the + platform will fetch (wrapper GDS, user_defines.v when required, .cf/project.json + when tracked) are clean at HEAD. + + On success the backend: + 1. Resolves the GitHub App installation for the project's `github_repo_url`. + 2. Selects the three push-critical blobs at `commit_sha` and asks the + SFTP home-dir Lambda to stage them into the customer's EFS landing zone. + 3. Syncs project.json (same as SFTP push) and, if requested, submits for review. + """ + from chipfoundry_cli.remote_precheck_git import RemotePushGitError, verify_push_repo + + cwd_root, cwd_project_name = get_project_json_from_cwd() + if not project_root and cwd_root: + project_root = cwd_root + if not project_name and cwd_project_name: + project_name = cwd_project_name + if not project_root: + console.print( + "[red]No project root specified and no .cf/project.json found in current directory.[/red]" + ) + console.print("Provide --project-root or run from a linked project.") + raise click.Abort() + project_root = str(Path(project_root).resolve()) + + platform_id = _load_project_platform_id(project_root) + if not platform_id: + console.print("[red]Project is not linked to the platform.[/red]") + console.print("Run [bold]cf link[/bold] to connect this project, or [bold]cf init[/bold] to create a new one.") + raise click.Abort() + + config = load_user_config() + if not config.get("api_key"): + console.print("[red]Not logged in.[/red] Run [bold]cf login[/bold] before using --remote.") + raise click.Abort() + + try: + head_sha, remote_ref = verify_push_repo(Path(project_root)) + except RemotePushGitError as e: + console.print(f"[red]Remote push not ready:[/red] {e}") + raise click.Abort() + + console.print( + f"[green]✓ Local checkout ready[/green] (HEAD [cyan]{head_sha[:7]}[/cyan] is on [cyan]{remote_ref}[/cyan])" + ) + + try: + project = _api_get(f"/projects/{platform_id}") + except SystemExit: + raise click.Abort() + + github_repo_url = (project.get("github_repo_url") or "").strip() + if not github_repo_url: + console.print( + "[red]This project has no GitHub repo URL configured.[/red]\n" + "Run [bold]cf init[/bold] and set the GitHub repo URL, or update it in the portal." + ) + raise click.Abort() + if not project.get("remote_precheck_github_ready"): + console.print( + "[red]The ChipFoundry GitHub App is not installed on this repository[/red] (or the URL is invalid).\n" + "Install it from the project page in the portal, then retry." + ) + raise click.Abort() + + final_project_name = project_name or Path(project_root).name + + if dry_run: + console.print("\n[bold]Remote push preview:[/bold]") + console.print(f" Platform project: {project.get('name')} ({platform_id})") + console.print(f" GitHub repo: {github_repo_url}") + console.print(f" Commit: {head_sha}") + console.print(f" Via remote ref: {remote_ref}") + console.print(f" EFS target: incoming/projects/{final_project_name}/") + console.print(" (no files uploaded — dry run)") + return + + console.print(f"Asking platform to fetch [cyan]{head_sha[:7]}[/cyan] from {github_repo_url}…") + try: + resp = _api_post( + f"/projects/{platform_id}/remote-push", + {"commit_sha": head_sha, "project_name": final_project_name}, + ) + except SystemExit: + raise click.Abort() + + landed = resp.get("landed") or [] + if landed: + console.print("[green]✓ Files staged on the platform:[/green]") + for rel in landed: + console.print(f" • {rel}") + else: + console.print("[yellow]⚠ Platform accepted the request but did not report any landed files.[/yellow]") + + try: + with open(Path(project_root) / ".cf" / "project.json", "r") as f: + pj = json.load(f) + _api_put( + f"/projects/{platform_id}", + {"cli_project_json": _slim_project_json(pj), "cli_sync_source": "push"}, + ) + console.print("[green]✓ Platform project synced[/green]") + except SystemExit: + console.print("[yellow]⚠ Remote push succeeded but platform sync failed[/yellow]") + except Exception: + console.print("[yellow]⚠ Could not read project.json for platform sync[/yellow]") + + if submit: + try: + _api_post(f"/projects/{platform_id}/submit", {}) + console.print("[green]✓ Project submitted for review[/green]") + except SystemExit: + console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]") + + @main.command('push') @click.option('--project-root', required=False, type=click.Path(exists=True, file_okay=False), help='Path to the local ChipFoundry project directory (defaults to current directory if .cf/project.json exists).') @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.') @@ -1307,8 +1525,17 @@ def action_quit(self) -> None: @click.option('--force-overwrite', is_flag=True, help='Overwrite existing files on SFTP without prompting.') @click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.') @click.option('--submit', is_flag=True, help='Submit the project for review after upload.') -def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit): - """Upload your project files to the ChipFoundry SFTP server.""" +@click.option('--remote', is_flag=True, help='Use the ChipFoundry GitHub App (HTTPS only) instead of SFTP. Useful when port 22 is blocked by a corporate firewall.') +def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote): + """Upload your project files to the ChipFoundry SFTP server (or via GitHub with --remote).""" + if remote: + _push_remote( + project_root=project_root, + project_name=project_name, + dry_run=dry_run, + submit=submit, + ) + return # If .cf/project.json exists in cwd, use it as default project_root and project_name cwd_root, cwd_project_name = get_project_json_from_cwd() if not project_root and cwd_root: diff --git a/chipfoundry_cli/remote_precheck_git.py b/chipfoundry_cli/remote_precheck_git.py index a4acef4..ea19261 100644 --- a/chipfoundry_cli/remote_precheck_git.py +++ b/chipfoundry_cli/remote_precheck_git.py @@ -226,3 +226,98 @@ def verify_remote_precheck_repo( raise RemotePrecheckGitError( f"{rel!r} has uncommitted changes. Commit or stash before remote precheck." ) + + +class RemotePushGitError(Exception): + """Local repository state is not consistent with origin for remote push.""" + + +def _head_on_any_remote_ref(repo: Path, head_sha: str) -> Optional[str]: + """Return a remote ref name that contains HEAD's commit, or None. + + Uses `git branch -r --contains` so the check passes for any remote branch + that has been pushed, without pinning to a single named branch. + """ + r = _run_git(repo, "branch", "-r", "--contains", head_sha) + if r.returncode != 0: + return None + for line in r.stdout.splitlines(): + name = line.strip().lstrip("*").strip() + if not name or " -> " in name: + continue + return name + return None + + +def _push_critical_paths(repo: Path, project_json: Path) -> Set[str]: + """Paths that must be clean at HEAD for a remote push to match local state.""" + kind_gds, gds_rel = _detect_wrapper_gds(repo) + out: Set[str] = {gds_rel} + + cf_type = _load_cf_project_type(project_json) + if cf_type and cf_type != kind_gds: + raise RemotePushGitError( + f".cf/project.json type is {cf_type!r} but the wrapper GDS indicates {kind_gds!r}. " + "Fix project type or GDS layout before remote push." + ) + + # user_defines.v is required except for openframe, matching collect_project_files(). + if kind_gds != "openframe": + ud = repo / USER_DEFINES_REL + if ud.is_file() or _path_tracked_in_git(repo, USER_DEFINES_REL): + out.add(USER_DEFINES_REL) + + if _path_tracked_in_git(repo, CF_PROJECT_JSON_REL): + out.add(CF_PROJECT_JSON_REL) + + return out + + +def verify_push_repo(project_root: Path) -> Tuple[str, str]: + """ + Ensure the local checkout is safe for a remote push: HEAD is reachable from + a remote branch and the files the platform will fetch are clean at HEAD. + + Returns (head_sha, remote_ref_containing_head). + """ + repo = project_root.resolve() + git_marker = repo / ".git" + if not (git_marker.is_dir() or git_marker.is_file()): + raise RemotePushGitError( + "Remote push requires a git checkout with .git " + "(clone your GitHub repo rather than using a plain folder copy)." + ) + + head_sha = _local_head_sha(repo) + remote_ref = _head_on_any_remote_ref(repo, head_sha) + if not remote_ref: + raise RemotePushGitError( + f"HEAD ({head_sha[:7]}) is not on any remote ref. " + "Push your commits to GitHub (e.g. `git push`) before running `cf push --remote`." + ) + + project_json = repo / ".cf" / "project.json" + critical = _push_critical_paths(repo, project_json) + + dirty = _porcelain_paths(repo) + for entry in dirty: + if entry.startswith("??"): + path = entry[2:] + if path in critical: + raise RemotePushGitError( + f"{path!r} is untracked but required for remote push. " + "Add and commit it (or remove it) so the remote fetch matches your machine." + ) + elif entry in critical: + raise RemotePushGitError( + f"{entry!r} has uncommitted changes. Commit and push before remote push." + ) + + for rel in sorted(critical): + r = _run_git(repo, "diff-index", "--quiet", "HEAD", "--", rel) + if r.returncode != 0: + raise RemotePushGitError( + f"{rel!r} has uncommitted changes. Commit and push before remote push." + ) + + return head_sha, remote_ref diff --git a/chipfoundry_cli/utils.py b/chipfoundry_cli/utils.py index 8b85c1b..4def908 100644 --- a/chipfoundry_cli/utils.py +++ b/chipfoundry_cli/utils.py @@ -1,7 +1,8 @@ import os import shutil +import subprocess from pathlib import Path -from typing import Dict, Optional, Any +from typing import Dict, List, Optional, Tuple, Any import json import hashlib import paramiko @@ -25,6 +26,64 @@ 'openframe_project_wrapper.gds.gz': 'openframe', } +# Canonical GDS wrapper layouts used by remote precheck and remote push. +# Each entry is (project_kind, base path without suffix). Suffixes below. +# Keep in sync with chipignite-backend-services/src/precheck_service and +# sftp-admin/lambda/CreateSftpHomeDirectory.py stage_push_files action. +GDS_WRAPPER_BASES: Tuple[Tuple[str, str], ...] = ( + ("analog", "gds/user_analog_project_wrapper"), + ("digital", "gds/user_project_wrapper"), + ("openframe", "gds/openframe_project_wrapper"), +) +GDS_WRAPPER_SUFFIXES: Tuple[str, ...] = (".gds", ".gds.gz") + +USER_DEFINES_REL = "verilog/rtl/user_defines.v" +CF_PROJECT_JSON_REL = ".cf/project.json" + + +def detect_github_repo_url(project_root: str) -> Optional[str]: + """ + Return a normalized https://github.com/owner/repo URL for `origin`, or None. + + Handles HTTPS remotes (with or without .git suffix) and SSH remotes + (git@github.com:owner/repo.git). Non-GitHub remotes return None silently + so callers can just pre-fill the prompt when a GitHub remote is present. + """ + try: + r = subprocess.run( + ["git", "-C", str(project_root), "remote", "get-url", "origin"], + capture_output=True, text=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if r.returncode != 0: + return None + raw = (r.stdout or "").strip() + if not raw: + return None + m = re.match(r"^git@github\.com:([^/]+)/(.+?)(?:\.git)?$", raw) + if m: + return f"https://github.com/{m.group(1)}/{m.group(2)}" + if raw.startswith(("https://github.com/", "http://github.com/")): + cleaned = raw.removesuffix(".git") + return cleaned.replace("http://", "https://", 1) + return None + + +def get_head_commit_sha(project_root: str) -> Optional[str]: + """Return the full commit SHA at HEAD, or None if not a git checkout.""" + try: + r = subprocess.run( + ["git", "-C", str(project_root), "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if r.returncode != 0: + return None + sha = (r.stdout or "").strip() + return sha if re.fullmatch(r"[0-9a-f]{40}", sha) else None + def collect_project_files(project_root: str) -> Dict[str, Optional[str]]: """ Collect required project files from the given project_root. From a22e8139e62dd692978d8275e47531a2039356b3 Mon Sep 17 00:00:00 2001 From: jeffdi Date: Sat, 18 Apr 2026 21:21:36 -0700 Subject: [PATCH 2/6] fix(init): require explicit choice when stored and detected values conflict When .cf/project.json and the workspace (e.g. git remote) disagree, the prompt now shows both and Enter accepts the detected value (ground truth). Type `k`/`keep` to keep the stored value instead. Previously Enter would silently keep the stale stored value, which let wrong github_repo_url values persist through repeated `cf init` runs. Made-with: Cursor --- README.md | 4 ++- chipfoundry_cli/main.py | 60 ++++++++++++++++++++++++++++++----------- pyproject.toml | 2 +- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7122968..a4c3350 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,9 @@ cf init [--project-root DIRECTORY] [--shuttle NAME_OR_ID] [--description TEXT] **What it does:** - **Idempotent refresh**: Running `cf init` again on an already-linked project pulls in the current platform values, pre-fills prompts, and only PUTs the differences you confirm. The `platform_project_id` link is preserved. - **Smart defaults**: Auto-detects project name from directory, project type from GDS files, and GitHub repo URL from your `origin` remote (HTTPS or SSH). -- **Interactive prompts**: Blank input keeps the current/detected value; type `clear` to explicitly remove a field. +- **Interactive prompts**: + - When a stored value and a detected value match (or only one exists), press Enter to accept it. + - When they **differ** (e.g. a stale `github_repo_url` in `.cf/project.json` vs. your current `git remote`), the prompt shows both and Enter accepts the detected value (ground truth). Type `k` or `keep` to keep the current value instead, type a new value to override, or type `clear` to remove the field entirely. - **Shuttle selection**: On first init, prompts to select an available shuttle (sorted by nearest deadline). - **Platform registration**: Creates the project on the platform and links it automatically. - Setting the GitHub repo URL enables `cf precheck --remote` and `cf push --remote`. diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 4b75c41..523d32e 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -309,26 +309,54 @@ def keyview(): _print_manual_key_instructions() def _prompt_with_default(label: str, current: Optional[str], detected: Optional[str] = None) -> Optional[str]: - """Interactive prompt that preserves `current` on blank input. - - - Enter = keep current (or accept detected when no current). - - Type a value = new value. - - Type 'clear' = explicitly remove the value (returns None). - Returned value is stripped. `clear` is case-insensitive. + """Interactive prompt with sensible defaults for current/detected values. + + Behavior: + - No current, no detected: Enter leaves the value unset (None). + - Only current: Enter keeps current. + - Only detected: Enter accepts detected. + - Current == detected: Enter accepts the (single) value. + - Current != detected: Enter accepts `detected` (ground truth, e.g. git + remote). Type `k` or `keep` to keep current. + Any typed value becomes the new value. `clear` (case-insensitive) explicitly + removes the value (returns None). """ - effective_default = current if current else detected - hint_parts = [] - if current: - hint_parts.append(f"current: [cyan]{current}[/cyan]") - if detected and detected != current: - hint_parts.append(f"detected: [cyan]{detected}[/cyan]") - hint_parts.append("blank=keep, 'clear'=remove") - hint = ", ".join(hint_parts) - raw = console.input(f"{label} ({hint}): ").strip() + normalized_current = current.strip() if isinstance(current, str) and current.strip() else None + normalized_detected = detected.strip() if isinstance(detected, str) and detected.strip() else None + conflict = ( + normalized_current is not None + and normalized_detected is not None + and normalized_current != normalized_detected + ) + + if conflict: + effective_default = normalized_detected + elif normalized_detected is not None: + effective_default = normalized_detected + else: + effective_default = normalized_current + + console.print(f"[bold]{label}[/bold]") + if normalized_current: + console.print(f" current: [cyan]{normalized_current}[/cyan]") + if normalized_detected and normalized_detected != normalized_current: + console.print(f" detected: [cyan]{normalized_detected}[/cyan]") + + if conflict: + hint = "enter=use detected, k=keep current, clear=remove, or type new value" + elif effective_default: + hint = "enter=accept, clear=remove, or type new value" + else: + hint = "enter=skip, or type value" + + raw = console.input(f" [dim]{hint}[/dim]: ").strip() if raw == "": return effective_default - if raw.lower() == "clear": + lowered = raw.lower() + if lowered == "clear": return None + if conflict and lowered in ("k", "keep"): + return normalized_current return raw diff --git a/pyproject.toml b/pyproject.toml index f7b3bf0..64152f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.3.14" +version = "2.3.15" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" From a9712ef19bf8a3e45cf7a5e5acd3fa2344324681 Mon Sep 17 00:00:00 2001 From: jeffdi Date: Sat, 18 Apr 2026 21:49:58 -0700 Subject: [PATCH 3/6] fix(push): 10 min timeout for cf push --remote API call Large openframe GDS uploads via the platform take several minutes. The default 15 s httpx timeout caused the CLI to abort while the backend and lambda were still legitimately processing the request. Override to 600 s and warn the user that the call may take several minutes. Made-with: Cursor --- chipfoundry_cli/main.py | 16 +++++++++++++--- pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 523d32e..3b00a63 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1505,10 +1505,12 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r return console.print(f"Asking platform to fetch [cyan]{head_sha[:7]}[/cyan] from {github_repo_url}…") + console.print("[dim](large files may take several minutes — please keep this terminal open)[/dim]") try: resp = _api_post( f"/projects/{platform_id}/remote-push", {"commit_sha": head_sha, "project_name": final_project_name}, + timeout=600.0, ) except SystemExit: raise click.Abort() @@ -4209,11 +4211,19 @@ def _api_get(path: str): client.close() -def _api_post(path: str, json_data: dict): - """Authenticated POST to the platform API. Returns parsed JSON or raises SystemExit.""" +def _api_post(path: str, json_data: dict, timeout: Optional[float] = None): + """Authenticated POST to the platform API. Returns parsed JSON or raises SystemExit. + + `timeout` (seconds) overrides the client default for this request only. + Use a large value for long-running endpoints such as remote-push, which + waits for the platform to fetch files from GitHub and stage them on EFS. + """ client, _ = _api_client() try: - resp = client.post(path, json=json_data) + kwargs = {"json": json_data} + if timeout is not None: + kwargs["timeout"] = timeout + resp = client.post(path, **kwargs) if resp.status_code == 401: console.print("[red]✗ API key is invalid or expired.[/red] Run [bold]cf login[/bold] to re-authenticate.") raise SystemExit(1) diff --git a/pyproject.toml b/pyproject.toml index 64152f3..597a36b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.3.15" +version = "2.3.16" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" From 7a52b14a94bea86cbe6bca206a20a1aa40e65bfa Mon Sep 17 00:00:00 2001 From: jeffdi Date: Sun, 19 Apr 2026 07:52:01 -0700 Subject: [PATCH 4/6] fix(push): extend _api_put timeout to 60s on the post-remote-push sync After a successful remote push, the CLI does a platform project sync via PUT /projects/{id}. This uses the default 15 s timeout which was too tight behind slow links or when the backend is warming up. Bump to 60 s for this specific call only; other PUTs keep the default. Made-with: Cursor --- chipfoundry_cli/main.py | 13 ++++++++++--- pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 3b00a63..368d595 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1529,6 +1529,7 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r _api_put( f"/projects/{platform_id}", {"cli_project_json": _slim_project_json(pj), "cli_sync_source": "push"}, + timeout=60.0, ) console.print("[green]✓ Platform project synced[/green]") except SystemExit: @@ -4238,11 +4239,17 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None): client.close() -def _api_put(path: str, json_data: dict): - """Authenticated PUT to the platform API. Returns parsed JSON or raises SystemExit.""" +def _api_put(path: str, json_data: dict, timeout: Optional[float] = None): + """Authenticated PUT to the platform API. Returns parsed JSON or raises SystemExit. + + `timeout` (seconds) overrides the client default for this request only. + """ client, _ = _api_client() try: - resp = client.put(path, json=json_data) + kwargs = {"json": json_data} + if timeout is not None: + kwargs["timeout"] = timeout + resp = client.put(path, **kwargs) if resp.status_code == 401: console.print("[red]✗ API key is invalid or expired.[/red] Run [bold]cf login[/bold] to re-authenticate.") raise SystemExit(1) diff --git a/pyproject.toml b/pyproject.toml index 597a36b..7fa8b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.3.16" +version = "2.3.17" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" From 4e54a33910ba916717da0b7c29b49a2f3c28addb Mon Sep 17 00:00:00 2001 From: jeffdi Date: Sun, 19 Apr 2026 08:40:50 -0700 Subject: [PATCH 5/6] fix(push): surface GDS-detection errors cleanly instead of a Python traceback _push_critical_paths calls _detect_wrapper_gds which raises the precheck exception class RemotePrecheckGitError. _push_remote only caught RemotePushGitError, so the precheck exception escaped as an unhandled traceback. Rewrap it under RemotePushGitError with a push-oriented hint (including a note about git lfs pull for LFS repos) and add a defensive catch-all at the call site as a backstop. Made-with: Cursor --- chipfoundry_cli/main.py | 3 +++ chipfoundry_cli/remote_precheck_git.py | 13 ++++++++++++- pyproject.toml | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 368d595..8de4b8f 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1468,6 +1468,9 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r except RemotePushGitError as e: console.print(f"[red]Remote push not ready:[/red] {e}") raise click.Abort() + except Exception as e: # defensive: never leak a raw traceback here + console.print(f"[red]Remote push could not verify the repo:[/red] {type(e).__name__}: {e}") + raise click.Abort() console.print( f"[green]✓ Local checkout ready[/green] (HEAD [cyan]{head_sha[:7]}[/cyan] is on [cyan]{remote_ref}[/cyan])" diff --git a/chipfoundry_cli/remote_precheck_git.py b/chipfoundry_cli/remote_precheck_git.py index ea19261..241ab2a 100644 --- a/chipfoundry_cli/remote_precheck_git.py +++ b/chipfoundry_cli/remote_precheck_git.py @@ -251,7 +251,18 @@ def _head_on_any_remote_ref(repo: Path, head_sha: str) -> Optional[str]: def _push_critical_paths(repo: Path, project_json: Path) -> Set[str]: """Paths that must be clean at HEAD for a remote push to match local state.""" - kind_gds, gds_rel = _detect_wrapper_gds(repo) + try: + kind_gds, gds_rel = _detect_wrapper_gds(repo) + except RemotePrecheckGitError as e: + # Re-raise under the push error class so the CLI surfaces one consistent + # message and can catch a single exception type. + raise RemotePushGitError( + f"{e} " + "Check that the wrapper GDS is committed and located under the " + "expected path (e.g. gds/user_project_wrapper.gds, " + "gds/openframe_project_wrapper.gds, etc.). If you use Git LFS, " + "run `git lfs pull` so the actual file (not the pointer) is present." + ) from e out: Set[str] = {gds_rel} cf_type = _load_cf_project_type(project_json) diff --git a/pyproject.toml b/pyproject.toml index 7fa8b6a..fc74fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.3.17" +version = "2.3.18" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" From 6dd1a0a821166557d2e1ce43a7a2cbcaf3b9b392 Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Sun, 19 Apr 2026 09:00:05 -0700 Subject: [PATCH 6/6] feat(push): include GitHub App install URL in "app not installed" error The platform project payload already exposes remote_precheck_github_app_install_url; surface it directly so the user can click through to install the ChipFoundry GitHub App on the right account instead of hunting through the portal. Made-with: Cursor --- chipfoundry_cli/main.py | 13 +++++++++++-- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 8de4b8f..b575a7c 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1489,10 +1489,19 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r ) raise click.Abort() if not project.get("remote_precheck_github_ready"): + install_url = (project.get("remote_precheck_github_app_install_url") or "").strip() console.print( - "[red]The ChipFoundry GitHub App is not installed on this repository[/red] (or the URL is invalid).\n" - "Install it from the project page in the portal, then retry." + "[red]The ChipFoundry GitHub App is not installed on this repository[/red] " + "(or the repo URL is wrong)." ) + if install_url: + console.print(f"Install the app here: [cyan]{install_url}[/cyan]") + console.print( + f"Make sure [bold]{github_repo_url}[/bold] is selected during installation, " + "then re-run [bold]cf push --remote[/bold]." + ) + else: + console.print("Install it from the project page in the portal, then retry.") raise click.Abort() final_project_name = project_name or Path(project_root).name diff --git a/pyproject.toml b/pyproject.toml index fc74fe6..8515868 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.3.18" +version = "2.3.19" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md"