chipfoundry-cli 2.4.0__tar.gz → 2.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 2.4.0
3
+ Version: 2.4.4
4
4
  Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
5
  Home-page: https://chipfoundry.io
6
6
  License: Apache-2.0
@@ -603,6 +603,7 @@ cf push [OPTIONS]
603
603
  - `--sftp-username`: Override configured username (SFTP mode only)
604
604
  - `--sftp-key`: Override configured key path (SFTP mode only)
605
605
  - `--remote`: HTTPS-only upload via the ChipFoundry GitHub App (no SFTP). Use this when port 22 is blocked by your corporate firewall.
606
+ - `--https`: HTTPS-only direct upload to AWS S3 (no SFTP, no GitHub). Use this when both SFTP and GitHub are blocked by your corporate firewall.
606
607
 
607
608
  **SFTP mode (default):**
608
609
  1. Verifies the project is linked to the platform and you are logged in
@@ -636,6 +637,34 @@ What happens:
636
637
  > network, run `cf push --remote` instead. No VPN required — just outbound
637
638
  > HTTPS and a GitHub repo linked to the project.
638
639
 
640
+ **HTTPS direct mode — `cf push --https`:**
641
+
642
+ Firewall-friendliest fallback: no SFTP, no GitHub, no Git at all. The CLI
643
+ uploads your push-critical files directly to an AWS S3 staging bucket over
644
+ HTTPS using short-lived pre-signed PUT URLs, then the platform stages the
645
+ objects onto your SFTP landing zone. Use this when your network blocks
646
+ both port 22 and GitHub.
647
+
648
+ Preconditions:
649
+ - Project is linked to the platform (`cf init` or `cf link`) and you are logged in.
650
+ - Wrapper GDS exists locally under `gds/` (one of `user_project_wrapper.gds[.gz]`,
651
+ `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`).
652
+ - For non-openframe projects, `verilog/rtl/user_defines.v` is also uploaded when present.
653
+ - Outbound HTTPS to `*.s3.us-east-2.amazonaws.com` is allowed.
654
+
655
+ What happens:
656
+ 1. `cf push --https` picks the wrapper GDS (and `user_defines.v` if applicable) and hashes each file with SHA-256 locally.
657
+ 2. Platform returns one pre-signed PUT URL per file; the CLI PUTs each file directly to S3 over HTTPS.
658
+ 3. Platform stages the objects onto your SFTP landing zone, re-verifying SHA-256 byte-for-byte and synthesizing `.cf/project.json` from the authoritative platform data.
659
+ 4. Staged S3 objects are deleted on success; any leftovers are expired by the bucket lifecycle after 7 days.
660
+ 5. `--submit` submits for review on success.
661
+
662
+ > [!TIP]
663
+ > Try modes in this order: `cf push` → `cf push --remote` → `cf push --https`.
664
+ > The SFTP mode is fastest when unrestricted, `--remote` is the best HTTPS
665
+ > option when you already keep the project on GitHub, and `--https` is the
666
+ > "direct upload" escape hatch that works even with no Git.
667
+
639
668
  **GDS File Handling:**
640
669
  - **Both compressed (`.gz`) and uncompressed (`.gds`) files are supported**
641
670
  - **No automatic compression** - files are uploaded as-is
@@ -577,6 +577,7 @@ cf push [OPTIONS]
577
577
  - `--sftp-username`: Override configured username (SFTP mode only)
578
578
  - `--sftp-key`: Override configured key path (SFTP mode only)
579
579
  - `--remote`: HTTPS-only upload via the ChipFoundry GitHub App (no SFTP). Use this when port 22 is blocked by your corporate firewall.
580
+ - `--https`: HTTPS-only direct upload to AWS S3 (no SFTP, no GitHub). Use this when both SFTP and GitHub are blocked by your corporate firewall.
580
581
 
581
582
  **SFTP mode (default):**
582
583
  1. Verifies the project is linked to the platform and you are logged in
@@ -610,6 +611,34 @@ What happens:
610
611
  > network, run `cf push --remote` instead. No VPN required — just outbound
611
612
  > HTTPS and a GitHub repo linked to the project.
612
613
 
614
+ **HTTPS direct mode — `cf push --https`:**
615
+
616
+ Firewall-friendliest fallback: no SFTP, no GitHub, no Git at all. The CLI
617
+ uploads your push-critical files directly to an AWS S3 staging bucket over
618
+ HTTPS using short-lived pre-signed PUT URLs, then the platform stages the
619
+ objects onto your SFTP landing zone. Use this when your network blocks
620
+ both port 22 and GitHub.
621
+
622
+ Preconditions:
623
+ - Project is linked to the platform (`cf init` or `cf link`) and you are logged in.
624
+ - Wrapper GDS exists locally under `gds/` (one of `user_project_wrapper.gds[.gz]`,
625
+ `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`).
626
+ - For non-openframe projects, `verilog/rtl/user_defines.v` is also uploaded when present.
627
+ - Outbound HTTPS to `*.s3.us-east-2.amazonaws.com` is allowed.
628
+
629
+ What happens:
630
+ 1. `cf push --https` picks the wrapper GDS (and `user_defines.v` if applicable) and hashes each file with SHA-256 locally.
631
+ 2. Platform returns one pre-signed PUT URL per file; the CLI PUTs each file directly to S3 over HTTPS.
632
+ 3. Platform stages the objects onto your SFTP landing zone, re-verifying SHA-256 byte-for-byte and synthesizing `.cf/project.json` from the authoritative platform data.
633
+ 4. Staged S3 objects are deleted on success; any leftovers are expired by the bucket lifecycle after 7 days.
634
+ 5. `--submit` submits for review on success.
635
+
636
+ > [!TIP]
637
+ > Try modes in this order: `cf push` → `cf push --remote` → `cf push --https`.
638
+ > The SFTP mode is fastest when unrestricted, `--remote` is the best HTTPS
639
+ > option when you already keep the project on GitHub, and `--https` is the
640
+ > "direct upload" escape hatch that works even with no Git.
641
+
613
642
  **GDS File Handling:**
614
643
  - **Both compressed (`.gz`) and uncompressed (`.gds`) files are supported**
615
644
  - **No automatic compression** - files are uploaded as-is
@@ -1,2 +1,2 @@
1
1
  """ChipFoundry CLI package: Automate project submission to SFTP."""
2
- __version__ = "2.3.2"
2
+ __version__ = "2.4.1"
@@ -1,6 +1,7 @@
1
1
  import click
2
2
  import getpass
3
- from typing import Optional, List
3
+ import hashlib
4
+ from typing import Optional, List, Tuple
4
5
  from chipfoundry_cli.check_refs import PRECHECK_CHECKS
5
6
  from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo
6
7
  from chipfoundry_cli.version_check import maybe_warn_outdated
@@ -402,8 +403,13 @@ def init(project_root, shuttle, description):
402
403
  local_proj = local_data.get('project', {}) if isinstance(local_data, dict) else {}
403
404
 
404
405
  config = load_user_config()
406
+ api_key = config.get('api_key')
405
407
  username = config.get("sftp_username")
406
- if not username:
408
+ # Try to refresh sftp_username from the platform, but don't block init on it.
409
+ # SFTP accounts are only auto-provisioned once a project deposit is paid/waived/
410
+ # sponsored and the user has an SSH key on their profile; init must work before
411
+ # that so users can configure locally and use `cf precheck` / `cf push --remote`.
412
+ if not username and api_key:
407
413
  try:
408
414
  me = _api_get("/auth/cli/whoami")
409
415
  username = me.get("sftp_username")
@@ -412,11 +418,10 @@ def init(project_root, shuttle, description):
412
418
  save_user_config(config)
413
419
  except SystemExit:
414
420
  pass
415
- if not username:
416
- console.print("[bold red]No SFTP account linked to your platform account. Please run 'cf login' first.[/bold red]")
417
- raise click.Abort()
418
-
419
- api_key = config.get('api_key')
421
+ # Fall back to email (or 'unknown') purely as a label in .cf/project.json.
422
+ # This field is metadata only: SFTP routing uses the live session identity,
423
+ # and the backend stores cli_project_json as an opaque blob.
424
+ user_label = username or config.get("user_email") or "unknown"
420
425
  platform_id = local_proj.get('platform_project_id')
421
426
  platform_proj: Optional[dict] = None
422
427
  if platform_id and api_key:
@@ -469,7 +474,7 @@ def init(project_root, shuttle, description):
469
474
  proj = data.setdefault('project', {})
470
475
  proj['name'] = name
471
476
  proj['type'] = project_type
472
- proj['user'] = username
477
+ proj['user'] = user_label
473
478
  proj.setdefault('version', local_proj.get('version') or "1")
474
479
  proj.setdefault('user_project_wrapper_hash', local_proj.get('user_project_wrapper_hash', ""))
475
480
  proj.setdefault('submission_state', local_proj.get('submission_state', "Draft"))
@@ -1568,6 +1573,263 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r
1568
1573
  console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]")
1569
1574
 
1570
1575
 
1576
+ def _sha256_file(path: Path) -> str:
1577
+ """SHA-256 of a file streamed in 4 KiB chunks.
1578
+
1579
+ Must match the shuttle importer and the Lambda side so the server can
1580
+ verify the upload byte-for-byte.
1581
+ """
1582
+ h = hashlib.sha256()
1583
+ with open(path, "rb") as f:
1584
+ for chunk in iter(lambda: f.read(4096), b""):
1585
+ h.update(chunk)
1586
+ return h.hexdigest()
1587
+
1588
+
1589
+ def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, str]]:
1590
+ """Return [(rel_path, abs_path, size, kind)] for files the platform
1591
+ accepts via --https.
1592
+
1593
+ Picks exactly one wrapper GDS (analog/digital/openframe) and, for
1594
+ non-openframe projects, ``verilog/rtl/user_defines.v`` if it exists.
1595
+ Raises FileNotFoundError if no wrapper is present or
1596
+ ValueError if multiple are.
1597
+ """
1598
+ from chipfoundry_cli.utils import GDS_WRAPPER_BASES, GDS_WRAPPER_SUFFIXES, USER_DEFINES_REL
1599
+
1600
+ hits: List[Tuple[str, str]] = []
1601
+ for kind, base in GDS_WRAPPER_BASES:
1602
+ for suf in GDS_WRAPPER_SUFFIXES:
1603
+ rel = base + suf
1604
+ if (project_root / rel).is_file():
1605
+ hits.append((kind, rel))
1606
+ break
1607
+ if not hits:
1608
+ raise FileNotFoundError(
1609
+ "No wrapper GDS found (expected one of gds/user_project_wrapper.gds[.gz], "
1610
+ "gds/user_analog_project_wrapper.gds[.gz], "
1611
+ "gds/openframe_project_wrapper.gds[.gz])."
1612
+ )
1613
+ if len(hits) > 1:
1614
+ paths = ", ".join(h[1] for h in hits)
1615
+ raise ValueError(
1616
+ f"Multiple wrapper GDS layouts present ({paths}). Keep only one."
1617
+ )
1618
+
1619
+ kind, wrapper_rel = hits[0]
1620
+ abs_wrapper = project_root / wrapper_rel
1621
+ results: List[Tuple[str, Path, int, str]] = [
1622
+ (wrapper_rel, abs_wrapper, abs_wrapper.stat().st_size, "wrapper")
1623
+ ]
1624
+
1625
+ if kind != "openframe":
1626
+ ud = project_root / USER_DEFINES_REL
1627
+ if ud.is_file():
1628
+ results.append((USER_DEFINES_REL, ud, ud.stat().st_size, "aux"))
1629
+
1630
+ return results
1631
+
1632
+
1633
+ def _push_https(project_root: Optional[str], project_name: Optional[str], dry_run: bool, submit: bool) -> None:
1634
+ """Push project files to the platform by uploading directly to S3 over HTTPS.
1635
+
1636
+ Use case: customers whose network blocks BOTH SFTP (port 22) and
1637
+ GitHub, so they cannot use `cf push` or `cf push --remote`. They can
1638
+ still reach AWS S3 over HTTPS, which is what the backend hands them
1639
+ via pre-signed PUT URLs.
1640
+
1641
+ No Git involvement at all — the CLI hashes the local files, the
1642
+ backend returns pre-signed URLs, the CLI PUTs directly to S3, and
1643
+ the platform stages the objects onto EFS with the same synthesized
1644
+ .cf/project.json the --remote flow produces.
1645
+ """
1646
+ cwd_root, cwd_project_name = get_project_json_from_cwd()
1647
+ if not project_root and cwd_root:
1648
+ project_root = cwd_root
1649
+ if not project_name and cwd_project_name:
1650
+ project_name = cwd_project_name
1651
+ if not project_root:
1652
+ console.print(
1653
+ "[red]No project root specified and no .cf/project.json found in current directory.[/red]"
1654
+ )
1655
+ console.print("Provide --project-root or run from a linked project.")
1656
+ raise click.Abort()
1657
+ project_root = str(Path(project_root).resolve())
1658
+
1659
+ platform_id = _load_project_platform_id(project_root)
1660
+ if not platform_id:
1661
+ console.print("[red]Project is not linked to the platform.[/red]")
1662
+ console.print("Run [bold]cf link[/bold] to connect this project, or [bold]cf init[/bold] to create a new one.")
1663
+ raise click.Abort()
1664
+
1665
+ config = load_user_config()
1666
+ if not config.get("api_key"):
1667
+ console.print("[red]Not logged in.[/red] Run [bold]cf login[/bold] before using --https.")
1668
+ raise click.Abort()
1669
+
1670
+ try:
1671
+ candidates = _collect_push_candidates(Path(project_root))
1672
+ except FileNotFoundError as e:
1673
+ console.print(f"[red]HTTPS push not ready:[/red] {e}")
1674
+ raise click.Abort()
1675
+ except ValueError as e:
1676
+ console.print(f"[red]HTTPS push not ready:[/red] {e}")
1677
+ raise click.Abort()
1678
+
1679
+ final_project_name = project_name or Path(project_root).name
1680
+
1681
+ total_bytes = sum(c[2] for c in candidates)
1682
+ mb = total_bytes / (1024 * 1024)
1683
+ console.print(
1684
+ f"[green]✓ Ready to upload[/green] [cyan]{len(candidates)}[/cyan] file(s) "
1685
+ f"([cyan]{mb:.1f} MiB[/cyan] total) to the platform over HTTPS."
1686
+ )
1687
+
1688
+ if dry_run:
1689
+ console.print("\n[bold]HTTPS push preview:[/bold]")
1690
+ console.print(f" Platform project: {platform_id}")
1691
+ console.print(f" Project name: {final_project_name}")
1692
+ for rel, abs_path, size, _ in candidates:
1693
+ console.print(f" • {rel} ({size / (1024 * 1024):.1f} MiB)")
1694
+ console.print(" (no files uploaded — dry run)")
1695
+ return
1696
+
1697
+ console.print("[dim]Hashing files locally…[/dim]")
1698
+ hashed: List[dict] = []
1699
+ for rel, abs_path, size, _ in candidates:
1700
+ digest = _sha256_file(abs_path)
1701
+ hashed.append({"rel_path": rel, "size": size, "sha256": digest})
1702
+ console.print(f" [dim]sha256[/dim] {digest[:16]}… {rel}")
1703
+
1704
+ console.print("Requesting upload slots from the platform…")
1705
+ try:
1706
+ init_resp = _api_post(
1707
+ f"/projects/{platform_id}/https-push/init",
1708
+ {"project_name": final_project_name, "files": hashed},
1709
+ timeout=60.0,
1710
+ )
1711
+ except SystemExit:
1712
+ raise click.Abort()
1713
+
1714
+ upload_id = init_resp.get("upload_id") or ""
1715
+ put_targets = {f["rel_path"]: f["put_url"] for f in (init_resp.get("files") or [])}
1716
+ if not upload_id or len(put_targets) != len(candidates):
1717
+ console.print("[red]✗ Platform did not return upload slots for every file.[/red]")
1718
+ raise click.Abort()
1719
+
1720
+ console.print(
1721
+ f"[dim]Upload id [bold]{upload_id[:8]}[/bold] — uploading to "
1722
+ f"{init_resp.get('bucket')} (HTTPS, {init_resp.get('expires_in', 3600)}s TTL)…[/dim]"
1723
+ )
1724
+
1725
+ # Per-file single PUT. We reuse one httpx client with a generous
1726
+ # timeout; the signed URL carries auth so no headers besides
1727
+ # x-amz-server-side-encryption are required.
1728
+ #
1729
+ # We stream the body with a generator instead of passing the file
1730
+ # directly so we can drive a rich progress bar (matches the UX of
1731
+ # the SFTP push path in utils.upload_with_progress). Content-Length
1732
+ # is set explicitly so S3 doesn't fall back to chunked encoding,
1733
+ # which pre-signed PUTs don't allow.
1734
+ import httpx
1735
+ from rich.progress import DownloadColumn, TransferSpeedColumn
1736
+
1737
+ put_timeout = httpx.Timeout(connect=10.0, read=1800.0, write=1800.0, pool=30.0)
1738
+ chunk_size = 1024 * 1024 # 1 MiB — big enough to keep overhead low, small enough for smooth bar updates
1739
+ with httpx.Client(timeout=put_timeout) as put_client:
1740
+ for rel, abs_path, size, _ in candidates:
1741
+ url = put_targets[rel]
1742
+ with Progress(
1743
+ TextColumn(" [cyan]↑[/cyan] [progress.description]{task.description}"),
1744
+ BarColumn(),
1745
+ TaskProgressColumn(),
1746
+ DownloadColumn(),
1747
+ TransferSpeedColumn(),
1748
+ TimeElapsedColumn(),
1749
+ console=console,
1750
+ transient=False,
1751
+ ) as progress:
1752
+ task = progress.add_task(rel, total=max(size, 1))
1753
+ if size == 0:
1754
+ # httpx won't call our generator for an empty body;
1755
+ # advance the bar manually so the user sees it complete.
1756
+ progress.update(task, completed=1)
1757
+
1758
+ def _body_iter(path=abs_path, tid=task, prog=progress):
1759
+ with open(path, "rb") as fh:
1760
+ while True:
1761
+ buf = fh.read(chunk_size)
1762
+ if not buf:
1763
+ break
1764
+ prog.update(tid, advance=len(buf))
1765
+ yield buf
1766
+
1767
+ try:
1768
+ resp = put_client.put(
1769
+ url,
1770
+ content=_body_iter() if size > 0 else b"",
1771
+ headers={
1772
+ "Content-Type": "application/octet-stream",
1773
+ "Content-Length": str(size),
1774
+ "x-amz-server-side-encryption": "AES256",
1775
+ },
1776
+ )
1777
+ if resp.status_code >= 300:
1778
+ body = resp.text[:300]
1779
+ console.print(
1780
+ f"[red]✗ Upload of {rel} failed: HTTP {resp.status_code} — {body}[/red]"
1781
+ )
1782
+ raise click.Abort()
1783
+ except click.Abort:
1784
+ raise
1785
+ except Exception as e:
1786
+ console.print(f"[red]✗ Upload of {rel} failed: {type(e).__name__}: {e}[/red]")
1787
+ raise click.Abort()
1788
+
1789
+ console.print("[green]✓ All files uploaded. Asking platform to stage them on EFS…[/green]")
1790
+ try:
1791
+ complete_resp = _api_post(
1792
+ f"/projects/{platform_id}/https-push/complete",
1793
+ {"upload_id": upload_id, "project_name": final_project_name},
1794
+ timeout=600.0,
1795
+ )
1796
+ except SystemExit:
1797
+ raise click.Abort()
1798
+
1799
+ landed = complete_resp.get("landed") or []
1800
+ if landed:
1801
+ console.print("[green]✓ Files staged on the platform:[/green]")
1802
+ for rel in landed:
1803
+ console.print(f" • {rel}")
1804
+ else:
1805
+ console.print("[yellow]⚠ Platform accepted the request but did not report any landed files.[/yellow]")
1806
+
1807
+ try:
1808
+ with open(Path(project_root) / ".cf" / "project.json", "r") as f:
1809
+ pj = json.load(f)
1810
+ _api_put(
1811
+ f"/projects/{platform_id}",
1812
+ {"cli_project_json": _slim_project_json(pj), "cli_sync_source": "push"},
1813
+ timeout=60.0,
1814
+ )
1815
+ console.print("[green]✓ Platform project synced[/green]")
1816
+ except FileNotFoundError:
1817
+ # .cf/project.json is synthesized server-side now; we still PUT the
1818
+ # local copy if present for UX parity, but it's not required.
1819
+ pass
1820
+ except SystemExit:
1821
+ console.print("[yellow]⚠ HTTPS push succeeded but platform sync failed[/yellow]")
1822
+ except Exception:
1823
+ console.print("[yellow]⚠ Could not read project.json for platform sync[/yellow]")
1824
+
1825
+ if submit:
1826
+ try:
1827
+ _api_post(f"/projects/{platform_id}/submit", {})
1828
+ console.print("[green]✓ Project submitted for review[/green]")
1829
+ except SystemExit:
1830
+ console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]")
1831
+
1832
+
1571
1833
  @main.command('push')
1572
1834
  @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).')
1573
1835
  @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
@@ -1580,8 +1842,25 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r
1580
1842
  @click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.')
1581
1843
  @click.option('--submit', is_flag=True, help='Submit the project for review after upload.')
1582
1844
  @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.')
1583
- def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote):
1584
- """Upload your project files to the ChipFoundry SFTP server (or via GitHub with --remote)."""
1845
+ @click.option('--https', 'https_mode', is_flag=True, help='Upload files directly over HTTPS (via S3 pre-signed URLs). Useful when both SFTP and GitHub are blocked.')
1846
+ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote, https_mode):
1847
+ """Upload your project files to the ChipFoundry SFTP server.
1848
+
1849
+ Defaults to SFTP. Use --remote to push via the ChipFoundry GitHub App
1850
+ (HTTPS), or --https to upload directly to AWS S3 (also HTTPS) without
1851
+ needing Git. The two HTTPS modes are mutually exclusive.
1852
+ """
1853
+ if remote and https_mode:
1854
+ console.print("[red]--remote and --https are mutually exclusive.[/red]")
1855
+ raise click.Abort()
1856
+ if https_mode:
1857
+ _push_https(
1858
+ project_root=project_root,
1859
+ project_name=project_name,
1860
+ dry_run=dry_run,
1861
+ submit=submit,
1862
+ )
1863
+ return
1585
1864
  if remote:
1586
1865
  _push_remote(
1587
1866
  project_root=project_root,
@@ -1619,7 +1898,11 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
1619
1898
  sftp_username = me.get("sftp_username")
1620
1899
  if not sftp_username:
1621
1900
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
1622
- console.print("Contact support or provide --sftp-username.")
1901
+ console.print(
1902
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
1903
+ "and an SSH public key is on your profile."
1904
+ )
1905
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
1623
1906
  raise click.Abort()
1624
1907
  config["sftp_username"] = sftp_username
1625
1908
  save_user_config(config)
@@ -1805,7 +2088,11 @@ def pull(project_name, output_dir, sftp_host, sftp_username, sftp_key):
1805
2088
  sftp_username = me.get("sftp_username")
1806
2089
  if not sftp_username:
1807
2090
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
1808
- console.print("Contact support or provide --sftp-username.")
2091
+ console.print(
2092
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
2093
+ "and an SSH public key is on your profile."
2094
+ )
2095
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
1809
2096
  raise click.Abort()
1810
2097
  config["sftp_username"] = sftp_username
1811
2098
  save_user_config(config)
@@ -2089,24 +2376,34 @@ def status(sftp_host, sftp_username, sftp_key, json_output, show_all):
2089
2376
  platform_id = _load_project_platform_id(os.getcwd())
2090
2377
  if not platform_id:
2091
2378
  console.print("[dim]Tip: Run [bold]cf link[/bold] to connect this project to the platform.[/dim]\n")
2379
+ # SFTP listing is a best-effort extra on top of the platform status above.
2380
+ # Skip it quietly when the user has no SFTP account yet (auto-provisioned
2381
+ # after a project deposit is paid/waived/sponsored + an SSH key is on file).
2092
2382
  if not sftp_username:
2093
- me = _api_get("/auth/cli/whoami")
2094
- sftp_username = me.get("sftp_username")
2383
+ if config.get("api_key"):
2384
+ try:
2385
+ me = _api_get("/auth/cli/whoami")
2386
+ sftp_username = me.get("sftp_username")
2387
+ except SystemExit:
2388
+ sftp_username = None
2095
2389
  if not sftp_username:
2096
- console.print("[red]No SFTP account linked to your platform account.[/red]")
2097
- console.print("Contact support or provide --sftp-username.")
2098
- raise click.Abort()
2390
+ console.print(
2391
+ "[dim]SFTP listing skipped no SFTP account linked yet. "
2392
+ "An account is provisioned once a project deposit is paid/waived/sponsored "
2393
+ "and an SSH public key is on your profile.[/dim]"
2394
+ )
2395
+ return
2099
2396
  config["sftp_username"] = sftp_username
2100
2397
  save_user_config(config)
2101
2398
  if not sftp_key:
2102
2399
  sftp_key = config.get("sftp_key")
2103
-
2400
+
2104
2401
  # Always resolve key_path to absolute path if set
2105
2402
  if sftp_key:
2106
2403
  key_path = os.path.abspath(os.path.expanduser(sftp_key))
2107
2404
  else:
2108
2405
  key_path = DEFAULT_SSH_KEY
2109
-
2406
+
2110
2407
  if not os.path.exists(key_path):
2111
2408
  console.print(f"[red]SFTP key file not found: {key_path}[/red]")
2112
2409
  console.print("[yellow]Please run 'cf keygen' to generate a key or 'cf config' to set a custom key path.[/yellow]")
@@ -2232,7 +2529,11 @@ def tapeouts(sftp_host, sftp_username, sftp_key, limit, days):
2232
2529
  sftp_username = me.get("sftp_username")
2233
2530
  if not sftp_username:
2234
2531
  console.print("[red]No SFTP account linked to your platform account.[/red]")
2235
- console.print("Contact support or provide --sftp-username.")
2532
+ console.print(
2533
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
2534
+ "and an SSH public key is on your profile."
2535
+ )
2536
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
2236
2537
  raise click.Abort()
2237
2538
  config["sftp_username"] = sftp_username
2238
2539
  save_user_config(config)
@@ -2422,7 +2723,11 @@ def confirm(project_root, sftp_host, sftp_username, sftp_key, project_name):
2422
2723
  sftp_username = me.get("sftp_username")
2423
2724
  if not sftp_username:
2424
2725
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
2425
- console.print("Contact support or provide --sftp-username.")
2726
+ console.print(
2727
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
2728
+ "and an SSH public key is on your profile."
2729
+ )
2730
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
2426
2731
  raise click.Abort()
2427
2732
  config["sftp_username"] = sftp_username
2428
2733
  save_user_config(config)
@@ -4276,8 +4581,39 @@ def _api_client():
4276
4581
  return client, api_url
4277
4582
 
4278
4583
 
4584
+ def _format_api_error(resp) -> str:
4585
+ """Build a user-friendly error message from a platform error response.
4586
+
4587
+ FastAPI returns errors as `{"detail": "..."}` (or a list of validation
4588
+ errors). Surfacing that instead of the bare `Client error '409 Conflict'`
4589
+ lets users act on the real reason without tailing backend logs.
4590
+ """
4591
+ status = resp.status_code
4592
+ try:
4593
+ body = resp.json()
4594
+ except Exception:
4595
+ snippet = (resp.text or "").strip()
4596
+ if snippet:
4597
+ return f"HTTP {status}: {snippet[:300]}"
4598
+ return f"HTTP {status}"
4599
+ detail = body.get("detail") if isinstance(body, dict) else None
4600
+ if isinstance(detail, str) and detail:
4601
+ return f"HTTP {status}: {detail}"
4602
+ if isinstance(detail, list) and detail:
4603
+ parts = []
4604
+ for item in detail:
4605
+ if isinstance(item, dict):
4606
+ loc = ".".join(str(p) for p in (item.get("loc") or [])[-2:])
4607
+ msg = item.get("msg") or ""
4608
+ parts.append(f"{loc}: {msg}" if loc else msg)
4609
+ if parts:
4610
+ return f"HTTP {status}: {'; '.join(parts)}"
4611
+ return f"HTTP {status}: {body}"
4612
+
4613
+
4279
4614
  def _api_get(path: str):
4280
4615
  """Authenticated GET to the platform API. Returns parsed JSON or raises SystemExit."""
4616
+ import httpx as _httpx
4281
4617
  client, _ = _api_client()
4282
4618
  try:
4283
4619
  resp = client.get(path)
@@ -4288,6 +4624,9 @@ def _api_get(path: str):
4288
4624
  return resp.json()
4289
4625
  except SystemExit:
4290
4626
  raise
4627
+ except _httpx.HTTPStatusError as e:
4628
+ console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]")
4629
+ raise SystemExit(1)
4291
4630
  except Exception as e:
4292
4631
  console.print(f"[red]✗ API request failed: {e}[/red]")
4293
4632
  raise SystemExit(1)
@@ -4302,6 +4641,7 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None):
4302
4641
  Use a large value for long-running endpoints such as remote-push, which
4303
4642
  waits for the platform to fetch files from GitHub and stage them on EFS.
4304
4643
  """
4644
+ import httpx as _httpx
4305
4645
  client, _ = _api_client()
4306
4646
  try:
4307
4647
  kwargs = {"json": json_data}
@@ -4315,6 +4655,9 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None):
4315
4655
  return resp.json()
4316
4656
  except SystemExit:
4317
4657
  raise
4658
+ except _httpx.HTTPStatusError as e:
4659
+ console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]")
4660
+ raise SystemExit(1)
4318
4661
  except Exception as e:
4319
4662
  console.print(f"[red]✗ API request failed: {e}[/red]")
4320
4663
  raise SystemExit(1)
@@ -4327,6 +4670,7 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None):
4327
4670
 
4328
4671
  `timeout` (seconds) overrides the client default for this request only.
4329
4672
  """
4673
+ import httpx as _httpx
4330
4674
  client, _ = _api_client()
4331
4675
  try:
4332
4676
  kwargs = {"json": json_data}
@@ -4340,6 +4684,9 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None):
4340
4684
  return resp.json()
4341
4685
  except SystemExit:
4342
4686
  raise
4687
+ except _httpx.HTTPStatusError as e:
4688
+ console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]")
4689
+ raise SystemExit(1)
4343
4690
  except Exception as e:
4344
4691
  console.print(f"[red]✗ API request failed: {e}[/red]")
4345
4692
  raise SystemExit(1)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.4.0"
3
+ version = "2.4.4"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"
File without changes