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.
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/PKG-INFO +30 -1
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/README.md +29 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/chipfoundry_cli/__init__.py +1 -1
- chipfoundry_cli-2.4.4/chipfoundry_cli/check_refs.py +42 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/chipfoundry_cli/main.py +478 -49
- chipfoundry_cli-2.4.4/chipfoundry_cli/version_check.py +204 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/pyproject.toml +1 -1
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/LICENSE +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/chipfoundry_cli/remote_precheck_git.py +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.4}/chipfoundry_cli/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: chipfoundry-cli
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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'] =
|
|
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
|
-
|
|
1573
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2083
|
-
|
|
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(
|
|
2086
|
-
|
|
2087
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
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={
|
|
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={
|
|
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(
|
|
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(
|
|
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={
|
|
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|