chipfoundry-cli 2.3.19__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.3.19
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"
@@ -0,0 +1,42 @@
1
+ """Known cf-precheck check names with display metadata.
2
+
3
+ Must stay in sync with cf-precheck's ``ALL_CHECKS`` ordering
4
+ (see ``cf-precheck/src/cf_precheck/check_manager.py``). The backend mirrors
5
+ the ref keys in ``chipignite-backend-services/src/precheck_service/check_refs.py``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import NamedTuple
11
+
12
+
13
+ class PrecheckCheck(NamedTuple):
14
+ ref: str
15
+ surname: str
16
+ optional: bool
17
+
18
+
19
+ PRECHECK_CHECKS: tuple[PrecheckCheck, ...] = (
20
+ PrecheckCheck("topcell_check", "Top Cell", False),
21
+ PrecheckCheck("gpio_defines", "GPIO Defines", False),
22
+ PrecheckCheck("pdnmulti", "PDN Multi", False),
23
+ PrecheckCheck("metalcheck", "Metal Check", False),
24
+ PrecheckCheck("xor", "XOR", False),
25
+ PrecheckCheck("magic_drc", "Magic DRC", True),
26
+ PrecheckCheck("klayout_feol", "Klayout FEOL", False),
27
+ PrecheckCheck("klayout_beol", "Klayout BEOL", False),
28
+ PrecheckCheck("klayout_offgrid", "Klayout Offgrid", False),
29
+ PrecheckCheck("klayout_met_min_ca_density", "Klayout Metal Density", False),
30
+ PrecheckCheck(
31
+ "klayout_pin_label_purposes_overlapping_drawing",
32
+ "Klayout Pin Label",
33
+ False,
34
+ ),
35
+ PrecheckCheck("klayout_zeroarea", "Klayout ZeroArea", False),
36
+ PrecheckCheck("spike_check", "Spike Check", False),
37
+ PrecheckCheck("illegal_cellname_check", "Illegal Cellname", False),
38
+ PrecheckCheck("lvs", "LVS", False),
39
+ PrecheckCheck("oeb", "OEB", False),
40
+ )
41
+
42
+ PRECHECK_CHECK_REFS: frozenset[str] = frozenset(c.ref for c in PRECHECK_CHECKS)
@@ -1,7 +1,10 @@
1
1
  import click
2
2
  import getpass
3
- from typing import Optional, List
3
+ import hashlib
4
+ from typing import Optional, List, Tuple
5
+ from chipfoundry_cli.check_refs import PRECHECK_CHECKS
4
6
  from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo
7
+ from chipfoundry_cli.version_check import maybe_warn_outdated
5
8
  from chipfoundry_cli.utils import (
6
9
  collect_project_files, ensure_cf_directory, update_or_create_project_json,
7
10
  sftp_connect, upload_with_progress, sftp_ensure_dirs, sftp_download_recursive,
@@ -174,7 +177,16 @@ def check_project_initialized(project_root_path: Path, command_name: str, dry_ru
174
177
  @click.group(help="ChipFoundry CLI: Automate project submission and management.")
175
178
  @click.version_option(importlib.metadata.version("chipfoundry-cli"), "-v", "--version", message="%(version)s")
176
179
  def main():
177
- pass
180
+ # Best-effort upgrade check. Cached on disk for CACHE_TTL_SECONDS and
181
+ # guarded by a short timeout so it never slows down a command. Runs
182
+ # only when a subcommand was dispatched — `cf --version` / `cf --help`
183
+ # exit before this callback fires.
184
+ try:
185
+ current = importlib.metadata.version("chipfoundry-cli")
186
+ maybe_warn_outdated(current, _get_api_url(), console, user_agent=_cf_user_agent())
187
+ except Exception:
188
+ # Never let a version-check issue break the actual command.
189
+ pass
178
190
 
179
191
  @main.command('config')
180
192
  def config_cmd():
@@ -391,8 +403,13 @@ def init(project_root, shuttle, description):
391
403
  local_proj = local_data.get('project', {}) if isinstance(local_data, dict) else {}
392
404
 
393
405
  config = load_user_config()
406
+ api_key = config.get('api_key')
394
407
  username = config.get("sftp_username")
395
- 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:
396
413
  try:
397
414
  me = _api_get("/auth/cli/whoami")
398
415
  username = me.get("sftp_username")
@@ -401,11 +418,10 @@ def init(project_root, shuttle, description):
401
418
  save_user_config(config)
402
419
  except SystemExit:
403
420
  pass
404
- if not username:
405
- console.print("[bold red]No SFTP account linked to your platform account. Please run 'cf login' first.[/bold red]")
406
- raise click.Abort()
407
-
408
- 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"
409
425
  platform_id = local_proj.get('platform_project_id')
410
426
  platform_proj: Optional[dict] = None
411
427
  if platform_id and api_key:
@@ -458,7 +474,7 @@ def init(project_root, shuttle, description):
458
474
  proj = data.setdefault('project', {})
459
475
  proj['name'] = name
460
476
  proj['type'] = project_type
461
- proj['user'] = username
477
+ proj['user'] = user_label
462
478
  proj.setdefault('version', local_proj.get('version') or "1")
463
479
  proj.setdefault('user_project_wrapper_hash', local_proj.get('user_project_wrapper_hash', ""))
464
480
  proj.setdefault('submission_state', local_proj.get('submission_state', "Draft"))
@@ -1557,6 +1573,263 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r
1557
1573
  console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]")
1558
1574
 
1559
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
+
1560
1833
  @main.command('push')
1561
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).')
1562
1835
  @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
@@ -1569,8 +1842,25 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r
1569
1842
  @click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.')
1570
1843
  @click.option('--submit', is_flag=True, help='Submit the project for review after upload.')
1571
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.')
1572
- def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote):
1573
- """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
1574
1864
  if remote:
1575
1865
  _push_remote(
1576
1866
  project_root=project_root,
@@ -1608,7 +1898,11 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
1608
1898
  sftp_username = me.get("sftp_username")
1609
1899
  if not sftp_username:
1610
1900
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
1611
- 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.")
1612
1906
  raise click.Abort()
1613
1907
  config["sftp_username"] = sftp_username
1614
1908
  save_user_config(config)
@@ -1794,7 +2088,11 @@ def pull(project_name, output_dir, sftp_host, sftp_username, sftp_key):
1794
2088
  sftp_username = me.get("sftp_username")
1795
2089
  if not sftp_username:
1796
2090
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
1797
- 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.")
1798
2096
  raise click.Abort()
1799
2097
  config["sftp_username"] = sftp_username
1800
2098
  save_user_config(config)
@@ -2078,24 +2376,34 @@ def status(sftp_host, sftp_username, sftp_key, json_output, show_all):
2078
2376
  platform_id = _load_project_platform_id(os.getcwd())
2079
2377
  if not platform_id:
2080
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).
2081
2382
  if not sftp_username:
2082
- me = _api_get("/auth/cli/whoami")
2083
- 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
2084
2389
  if not sftp_username:
2085
- console.print("[red]No SFTP account linked to your platform account.[/red]")
2086
- console.print("Contact support or provide --sftp-username.")
2087
- 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
2088
2396
  config["sftp_username"] = sftp_username
2089
2397
  save_user_config(config)
2090
2398
  if not sftp_key:
2091
2399
  sftp_key = config.get("sftp_key")
2092
-
2400
+
2093
2401
  # Always resolve key_path to absolute path if set
2094
2402
  if sftp_key:
2095
2403
  key_path = os.path.abspath(os.path.expanduser(sftp_key))
2096
2404
  else:
2097
2405
  key_path = DEFAULT_SSH_KEY
2098
-
2406
+
2099
2407
  if not os.path.exists(key_path):
2100
2408
  console.print(f"[red]SFTP key file not found: {key_path}[/red]")
2101
2409
  console.print("[yellow]Please run 'cf keygen' to generate a key or 'cf config' to set a custom key path.[/yellow]")
@@ -2221,7 +2529,11 @@ def tapeouts(sftp_host, sftp_username, sftp_key, limit, days):
2221
2529
  sftp_username = me.get("sftp_username")
2222
2530
  if not sftp_username:
2223
2531
  console.print("[red]No SFTP account linked to your platform account.[/red]")
2224
- 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.")
2225
2537
  raise click.Abort()
2226
2538
  config["sftp_username"] = sftp_username
2227
2539
  save_user_config(config)
@@ -2411,7 +2723,11 @@ def confirm(project_root, sftp_host, sftp_username, sftp_key, project_name):
2411
2723
  sftp_username = me.get("sftp_username")
2412
2724
  if not sftp_username:
2413
2725
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
2414
- 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.")
2415
2731
  raise click.Abort()
2416
2732
  config["sftp_username"] = sftp_username
2417
2733
  save_user_config(config)
@@ -3553,11 +3869,62 @@ def _upload_precheck_results(project_json_path: Path):
3553
3869
  console.print("[yellow]⚠ Precheck results could not be synced to platform[/yellow]")
3554
3870
 
3555
3871
 
3556
- @main.command('precheck')
3872
+ def _print_precheck_checks() -> None:
3873
+ """Print the list of available precheck checks as a table."""
3874
+ table = Table(title="Available cf-precheck checks", show_lines=False)
3875
+ table.add_column("Ref", style="cyan", no_wrap=True)
3876
+ table.add_column("Name", style="white")
3877
+ table.add_column("Default", style="green")
3878
+ for c in PRECHECK_CHECKS:
3879
+ default = "opt-in" if c.optional else "on"
3880
+ table.add_row(c.ref, c.surname, default)
3881
+ console.print(table)
3882
+ console.print(
3883
+ "\n[dim]Use [bold]--checks REF[/bold] to run only specific checks, "
3884
+ "[bold]--skip-checks REF[/bold] to skip, or [bold]--magic-drc[/bold] "
3885
+ "to include the optional Magic DRC check.[/dim]"
3886
+ )
3887
+
3888
+
3889
+ def _build_precheck_help() -> str:
3890
+ """Build the --help text, including the list of available checks."""
3891
+ lines = [
3892
+ "Run precheck validation on the project.",
3893
+ "",
3894
+ "This runs the cf-precheck tool to validate your design before submission.",
3895
+ "",
3896
+ "\b",
3897
+ "Examples:",
3898
+ " cf precheck # Run all checks",
3899
+ " cf precheck --list-checks # List available checks and exit",
3900
+ " cf precheck --skip-checks lvs # Skip LVS check",
3901
+ " cf precheck --magic-drc # Include optional Magic DRC",
3902
+ " cf precheck --checks topcell_check # Run specific checks only",
3903
+ " cf precheck --remote # Queue on platform; exit when accepted",
3904
+ " cf precheck --remote --poll # Wait and stream progress",
3905
+ " cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit)",
3906
+ "",
3907
+ "\b",
3908
+ "Available checks (pass to --checks / --skip-checks):",
3909
+ ]
3910
+ for c in PRECHECK_CHECKS:
3911
+ suffix = " (optional; opt in via --magic-drc)" if c.optional else ""
3912
+ lines.append(f" {c.ref}{suffix}")
3913
+ lines += [
3914
+ "",
3915
+ "Remote precheck requires your local HEAD to match origin for --git-ref, and",
3916
+ "precheck inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check",
3917
+ "runs, and tracked .cf/project.json) to match that commit.",
3918
+ ]
3919
+ return "\n".join(lines)
3920
+
3921
+
3922
+ @main.command('precheck', help=_build_precheck_help())
3557
3923
  @click.option('--project-root', type=click.Path(exists=True, file_okay=False), help='Path to the project directory (defaults to current directory)')
3558
- @click.option('--skip-checks', multiple=True, help='Checks to skip (can be specified multiple times)')
3924
+ @click.option('--skip-checks', multiple=True, help='Checks to skip (repeatable). See --list-checks for valid refs.')
3559
3925
  @click.option('--magic-drc', is_flag=True, help='Include Magic DRC check (optional, off by default)')
3560
- @click.option('--checks', multiple=True, help='Specific checks to run (can be specified multiple times)')
3926
+ @click.option('--checks', multiple=True, help='Specific checks to run (repeatable). See --list-checks for valid refs.')
3927
+ @click.option('--list-checks', 'list_checks', is_flag=True, help='List the available precheck checks and exit.')
3561
3928
  @click.option('--dry-run', is_flag=True, help='Show the command without running')
3562
3929
  @click.option('--remote', is_flag=True, help='Queue precheck on the ChipFoundry platform (requires cf login + linked project)')
3563
3930
  @click.option(
@@ -3573,25 +3940,10 @@ def _upload_precheck_results(project_json_path: Path):
3573
3940
  show_default=True,
3574
3941
  help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.',
3575
3942
  )
3576
- def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll, git_ref, wait_timeout):
3577
- """Run precheck validation on the project.
3578
-
3579
- This runs the cf-precheck tool to validate your design before
3580
- submission.
3581
-
3582
- Examples:
3583
- cf precheck # Run all checks
3584
- cf precheck --skip-checks lvs # Skip LVS check
3585
- cf precheck --magic-drc # Include optional Magic DRC
3586
- cf precheck --checks topcell_check # Run specific checks only
3587
- cf precheck --remote # Queue on platform; exit when accepted
3588
- cf precheck --remote --poll # Wait and stream progress
3589
- cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit)
3590
-
3591
- Remote precheck requires your local HEAD to match origin for --git-ref, and precheck
3592
- inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check runs, and tracked
3593
- .cf/project.json) to match that commit.
3594
- """
3943
+ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, remote, poll, git_ref, wait_timeout):
3944
+ if list_checks:
3945
+ _print_precheck_checks()
3946
+ return
3595
3947
  cwd_root, _ = get_project_json_from_cwd()
3596
3948
  if not project_root and cwd_root:
3597
3949
  project_root = cwd_root
@@ -3659,7 +4011,10 @@ def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll
3659
4011
  api_url = _get_api_url()
3660
4012
  client = httpx_remote.Client(
3661
4013
  base_url=f"{api_url}/api/v1",
3662
- headers={'Authorization': f'Bearer {api_key}'},
4014
+ headers={
4015
+ 'Authorization': f'Bearer {api_key}',
4016
+ 'User-Agent': _cf_user_agent(),
4017
+ },
3663
4018
  timeout=120.0,
3664
4019
  )
3665
4020
  try:
@@ -4174,6 +4529,24 @@ DEFAULT_API_URL = 'https://api.chipfoundry.io'
4174
4529
  PORTAL_BASE_URL = 'https://platform.chipfoundry.io'
4175
4530
 
4176
4531
 
4532
+ def _cf_user_agent() -> str:
4533
+ """User-Agent string for platform requests.
4534
+
4535
+ Format: ``chipfoundry-cli/<cli-version> python/<py-version> <platform>``.
4536
+ Lets the backend track which CLI versions are in the wild without a
4537
+ dedicated telemetry endpoint.
4538
+ """
4539
+ import platform as _platform
4540
+
4541
+ try:
4542
+ cli_version = importlib.metadata.version("chipfoundry-cli")
4543
+ except importlib.metadata.PackageNotFoundError:
4544
+ cli_version = "unknown"
4545
+ py = _platform.python_version()
4546
+ system = f"{_platform.system().lower()}-{_platform.machine().lower()}"
4547
+ return f"chipfoundry-cli/{cli_version} python/{py} {system}"
4548
+
4549
+
4177
4550
  def _get_api_url() -> str:
4178
4551
  config = load_user_config()
4179
4552
  return config.get('api_url', DEFAULT_API_URL)
@@ -4199,14 +4572,48 @@ def _api_client():
4199
4572
  api_url = _get_api_url()
4200
4573
  client = httpx.Client(
4201
4574
  base_url=f"{api_url}/api/v1",
4202
- headers={'Authorization': f'Bearer {api_key}'},
4575
+ headers={
4576
+ 'Authorization': f'Bearer {api_key}',
4577
+ 'User-Agent': _cf_user_agent(),
4578
+ },
4203
4579
  timeout=15,
4204
4580
  )
4205
4581
  return client, api_url
4206
4582
 
4207
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
+
4208
4614
  def _api_get(path: str):
4209
4615
  """Authenticated GET to the platform API. Returns parsed JSON or raises SystemExit."""
4616
+ import httpx as _httpx
4210
4617
  client, _ = _api_client()
4211
4618
  try:
4212
4619
  resp = client.get(path)
@@ -4217,6 +4624,9 @@ def _api_get(path: str):
4217
4624
  return resp.json()
4218
4625
  except SystemExit:
4219
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)
4220
4630
  except Exception as e:
4221
4631
  console.print(f"[red]✗ API request failed: {e}[/red]")
4222
4632
  raise SystemExit(1)
@@ -4231,6 +4641,7 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None):
4231
4641
  Use a large value for long-running endpoints such as remote-push, which
4232
4642
  waits for the platform to fetch files from GitHub and stage them on EFS.
4233
4643
  """
4644
+ import httpx as _httpx
4234
4645
  client, _ = _api_client()
4235
4646
  try:
4236
4647
  kwargs = {"json": json_data}
@@ -4244,6 +4655,9 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None):
4244
4655
  return resp.json()
4245
4656
  except SystemExit:
4246
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)
4247
4661
  except Exception as e:
4248
4662
  console.print(f"[red]✗ API request failed: {e}[/red]")
4249
4663
  raise SystemExit(1)
@@ -4256,6 +4670,7 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None):
4256
4670
 
4257
4671
  `timeout` (seconds) overrides the client default for this request only.
4258
4672
  """
4673
+ import httpx as _httpx
4259
4674
  client, _ = _api_client()
4260
4675
  try:
4261
4676
  kwargs = {"json": json_data}
@@ -4269,6 +4684,9 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None):
4269
4684
  return resp.json()
4270
4685
  except SystemExit:
4271
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)
4272
4690
  except Exception as e:
4273
4691
  console.print(f"[red]✗ API request failed: {e}[/red]")
4274
4692
  raise SystemExit(1)
@@ -4435,7 +4853,11 @@ def login_cmd(test):
4435
4853
  console.print(f"Opening browser to authenticate with [bold]{api_url}[/bold]...\n")
4436
4854
 
4437
4855
  try:
4438
- resp = httpx.post(f"{api_url}/api/v1/auth/cli/sessions", timeout=10)
4856
+ resp = httpx.post(
4857
+ f"{api_url}/api/v1/auth/cli/sessions",
4858
+ headers={'User-Agent': _cf_user_agent()},
4859
+ timeout=10,
4860
+ )
4439
4861
  resp.raise_for_status()
4440
4862
  data = resp.json()
4441
4863
  except httpx.HTTPError as e:
@@ -4457,7 +4879,11 @@ def login_cmd(test):
4457
4879
  for _ in range(max_polls):
4458
4880
  time.sleep(poll_interval)
4459
4881
  try:
4460
- poll_resp = httpx.get(poll_url, timeout=10)
4882
+ poll_resp = httpx.get(
4883
+ poll_url,
4884
+ headers={'User-Agent': _cf_user_agent()},
4885
+ timeout=10,
4886
+ )
4461
4887
  poll_resp.raise_for_status()
4462
4888
  poll_data = poll_resp.json()
4463
4889
  except httpx.HTTPError:
@@ -4528,7 +4954,10 @@ def whoami_cmd():
4528
4954
  try:
4529
4955
  resp = httpx.get(
4530
4956
  f"{api_url}/api/v1/auth/cli/whoami",
4531
- headers={'Authorization': f'Bearer {api_key}'},
4957
+ headers={
4958
+ 'Authorization': f'Bearer {api_key}',
4959
+ 'User-Agent': _cf_user_agent(),
4960
+ },
4532
4961
  timeout=10,
4533
4962
  )
4534
4963
  if resp.status_code == 401:
@@ -0,0 +1,204 @@
1
+ """Client-side upgrade check for the ``cf`` CLI.
2
+
3
+ Polls ``GET /api/v1/cli/version`` on the public API with a short timeout
4
+ and caches the response on disk so we only hit the network a few times
5
+ per day per user. Prints a dim warning if the installed version is
6
+ behind the latest published release. All errors are swallowed — a
7
+ failed check must never block or delay a normal command.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from rich.console import Console
20
+
21
+ from chipfoundry_cli.utils import get_config_path
22
+
23
+
24
+ CACHE_FILENAME = "version_check.json"
25
+ CACHE_TTL_SECONDS = 6 * 60 * 60 # 6 hours
26
+ NETWORK_TIMEOUT_SECONDS = 1.5
27
+ ENDPOINT_PATH = "/api/v1/cli/version"
28
+ ENV_DISABLE = "CF_SKIP_VERSION_CHECK"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class VersionInfo:
33
+ latest: str
34
+ minimum_supported: str
35
+ upgrade_command: str
36
+ release_notes_url: str
37
+
38
+
39
+ def _cache_path() -> Path:
40
+ return get_config_path().parent / CACHE_FILENAME
41
+
42
+
43
+ def _parse_semver(version: str) -> tuple[int, ...]:
44
+ """Parse ``X.Y.Z`` (optionally with pre-release suffix) into a tuple.
45
+
46
+ Unknown or non-numeric components are treated as 0 so a malformed
47
+ version never crashes the CLI. This is intentionally lightweight —
48
+ we don't pull in ``packaging`` just for this.
49
+ """
50
+ cleaned = version.strip().lstrip("v")
51
+ # Drop any pre-release / build-metadata suffix (e.g. ``1.2.3-rc1+sha``).
52
+ for sep in ("-", "+"):
53
+ if sep in cleaned:
54
+ cleaned = cleaned.split(sep, 1)[0]
55
+ parts: list[int] = []
56
+ for piece in cleaned.split("."):
57
+ try:
58
+ parts.append(int(piece))
59
+ except ValueError:
60
+ parts.append(0)
61
+ while len(parts) < 3:
62
+ parts.append(0)
63
+ return tuple(parts[:3])
64
+
65
+
66
+ def _is_older(current: str, target: str) -> bool:
67
+ """Return True if ``current`` is strictly older than ``target``."""
68
+ return _parse_semver(current) < _parse_semver(target)
69
+
70
+
71
+ def _read_cache() -> Optional[dict]:
72
+ path = _cache_path()
73
+ if not path.exists():
74
+ return None
75
+ try:
76
+ with open(path, "r") as f:
77
+ data = json.load(f)
78
+ except (OSError, json.JSONDecodeError):
79
+ return None
80
+ if not isinstance(data, dict):
81
+ return None
82
+ ts = data.get("fetched_at")
83
+ if not isinstance(ts, (int, float)):
84
+ return None
85
+ if (time.time() - ts) > CACHE_TTL_SECONDS:
86
+ return None
87
+ return data
88
+
89
+
90
+ def _write_cache(payload: dict) -> None:
91
+ path = _cache_path()
92
+ try:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ tmp = path.with_suffix(".json.tmp")
95
+ with open(tmp, "w") as f:
96
+ json.dump(payload, f)
97
+ tmp.replace(path)
98
+ except OSError:
99
+ # Cache is an optimization; never fail the command because we
100
+ # couldn't write it.
101
+ pass
102
+
103
+
104
+ def _fetch_latest(api_url: str, user_agent: Optional[str] = None) -> Optional[VersionInfo]:
105
+ """Hit the platform endpoint. Returns None on any failure."""
106
+ import httpx
107
+
108
+ url = f"{api_url.rstrip('/')}{ENDPOINT_PATH}"
109
+ headers = {"User-Agent": user_agent} if user_agent else {}
110
+ try:
111
+ resp = httpx.get(url, headers=headers, timeout=NETWORK_TIMEOUT_SECONDS)
112
+ resp.raise_for_status()
113
+ body = resp.json()
114
+ except Exception:
115
+ return None
116
+ try:
117
+ return VersionInfo(
118
+ latest=str(body["latest"]),
119
+ minimum_supported=str(body["minimum_supported"]),
120
+ upgrade_command=str(body["upgrade_command"]),
121
+ release_notes_url=str(body.get("release_notes_url", "")),
122
+ )
123
+ except (KeyError, TypeError):
124
+ return None
125
+
126
+
127
+ def _load_or_fetch(api_url: str, user_agent: Optional[str] = None) -> Optional[VersionInfo]:
128
+ cached = _read_cache()
129
+ if cached is not None and "info" in cached:
130
+ info = cached["info"]
131
+ try:
132
+ return VersionInfo(
133
+ latest=str(info["latest"]),
134
+ minimum_supported=str(info["minimum_supported"]),
135
+ upgrade_command=str(info["upgrade_command"]),
136
+ release_notes_url=str(info.get("release_notes_url", "")),
137
+ )
138
+ except (KeyError, TypeError):
139
+ pass
140
+ fresh = _fetch_latest(api_url, user_agent=user_agent)
141
+ if fresh is not None:
142
+ _write_cache(
143
+ {
144
+ "fetched_at": time.time(),
145
+ "info": {
146
+ "latest": fresh.latest,
147
+ "minimum_supported": fresh.minimum_supported,
148
+ "upgrade_command": fresh.upgrade_command,
149
+ "release_notes_url": fresh.release_notes_url,
150
+ },
151
+ }
152
+ )
153
+ return fresh
154
+
155
+
156
+ def maybe_warn_outdated(
157
+ current_version: str,
158
+ api_url: str,
159
+ console: Console,
160
+ user_agent: Optional[str] = None,
161
+ ) -> None:
162
+ """Print a warning if the installed CLI is behind.
163
+
164
+ Two severity tiers:
165
+
166
+ * **Below ``minimum_supported``** → prominent red warning. The
167
+ server will already reject these requests with HTTP 426 (see
168
+ :mod:`src.cli_version_service.hard_floor`); we surface the
169
+ upgrade instruction here so users learn *why* before they see the
170
+ error on their next real command.
171
+ * **Behind ``latest`` but at/above minimum** → dim yellow tip.
172
+ Purely informational.
173
+
174
+ Never raises.
175
+ """
176
+ if os.environ.get(ENV_DISABLE, "").strip() not in ("", "0", "false", "False"):
177
+ return
178
+ try:
179
+ info = _load_or_fetch(api_url, user_agent=user_agent)
180
+ except Exception:
181
+ return
182
+ if info is None:
183
+ return
184
+
185
+ notes = (
186
+ f" ({info.release_notes_url})" if info.release_notes_url else ""
187
+ )
188
+
189
+ if _is_older(current_version, info.minimum_supported):
190
+ console.print(
191
+ f"[red]✗ cf {current_version} is below the minimum supported "
192
+ f"version ({info.minimum_supported}).[/red] The platform will "
193
+ f"reject API calls from this install.\n"
194
+ f" Upgrade now: [cyan]{info.upgrade_command}[/cyan]{notes}"
195
+ )
196
+ return
197
+
198
+ if _is_older(current_version, info.latest):
199
+ console.print(
200
+ f"[yellow]⚠[/yellow] A newer [bold]cf[/bold] is available: "
201
+ f"[bold]{info.latest}[/bold] (you have {current_version}).\n"
202
+ f" Upgrade: [cyan]{info.upgrade_command}[/cyan]{notes}",
203
+ style="dim",
204
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.3.19"
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"