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