chipfoundry-cli 2.4.1__tar.gz → 2.4.5__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.1 → chipfoundry_cli-2.4.5}/PKG-INFO +30 -1
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/README.md +29 -0
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/chipfoundry_cli/main.py +340 -6
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/pyproject.toml +1 -1
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/LICENSE +0 -0
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/chipfoundry_cli/__init__.py +0 -0
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/chipfoundry_cli/check_refs.py +0 -0
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/chipfoundry_cli/remote_precheck_git.py +0 -0
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/chipfoundry_cli/utils.py +0 -0
- {chipfoundry_cli-2.4.1 → chipfoundry_cli-2.4.5}/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.5
|
|
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,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
|
|
@@ -204,16 +205,33 @@ def config_cmd():
|
|
|
204
205
|
def _try_register_ssh_key(public_key: str) -> bool:
|
|
205
206
|
"""Attempt to register the SSH public key on the user's platform profile.
|
|
206
207
|
|
|
207
|
-
|
|
208
|
+
Calls the CLI-specific ``PUT /auth/cli/ssh-key`` endpoint so the request
|
|
209
|
+
stays on the public API surface. Returns True on success, False otherwise.
|
|
210
|
+
Errors are swallowed silently so the caller can print the manual-registration
|
|
211
|
+
fallback without a scary ``API request failed`` line first.
|
|
208
212
|
"""
|
|
213
|
+
import httpx as _httpx
|
|
214
|
+
|
|
209
215
|
config = load_user_config()
|
|
210
216
|
if not config.get("api_key"):
|
|
211
217
|
return False
|
|
218
|
+
|
|
212
219
|
try:
|
|
213
|
-
|
|
214
|
-
return True
|
|
220
|
+
client, _ = _api_client()
|
|
215
221
|
except SystemExit:
|
|
216
222
|
return False
|
|
223
|
+
try:
|
|
224
|
+
resp = client.put("/auth/cli/ssh-key", json={"ssh_public_key": public_key})
|
|
225
|
+
if resp.status_code == 200:
|
|
226
|
+
return True
|
|
227
|
+
return False
|
|
228
|
+
except _httpx.HTTPError:
|
|
229
|
+
return False
|
|
230
|
+
finally:
|
|
231
|
+
try:
|
|
232
|
+
client.close()
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
217
235
|
|
|
218
236
|
|
|
219
237
|
def _print_manual_key_instructions():
|
|
@@ -1572,6 +1590,263 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r
|
|
|
1572
1590
|
console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]")
|
|
1573
1591
|
|
|
1574
1592
|
|
|
1593
|
+
def _sha256_file(path: Path) -> str:
|
|
1594
|
+
"""SHA-256 of a file streamed in 4 KiB chunks.
|
|
1595
|
+
|
|
1596
|
+
Must match the shuttle importer and the Lambda side so the server can
|
|
1597
|
+
verify the upload byte-for-byte.
|
|
1598
|
+
"""
|
|
1599
|
+
h = hashlib.sha256()
|
|
1600
|
+
with open(path, "rb") as f:
|
|
1601
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
1602
|
+
h.update(chunk)
|
|
1603
|
+
return h.hexdigest()
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, str]]:
|
|
1607
|
+
"""Return [(rel_path, abs_path, size, kind)] for files the platform
|
|
1608
|
+
accepts via --https.
|
|
1609
|
+
|
|
1610
|
+
Picks exactly one wrapper GDS (analog/digital/openframe) and, for
|
|
1611
|
+
non-openframe projects, ``verilog/rtl/user_defines.v`` if it exists.
|
|
1612
|
+
Raises FileNotFoundError if no wrapper is present or
|
|
1613
|
+
ValueError if multiple are.
|
|
1614
|
+
"""
|
|
1615
|
+
from chipfoundry_cli.utils import GDS_WRAPPER_BASES, GDS_WRAPPER_SUFFIXES, USER_DEFINES_REL
|
|
1616
|
+
|
|
1617
|
+
hits: List[Tuple[str, str]] = []
|
|
1618
|
+
for kind, base in GDS_WRAPPER_BASES:
|
|
1619
|
+
for suf in GDS_WRAPPER_SUFFIXES:
|
|
1620
|
+
rel = base + suf
|
|
1621
|
+
if (project_root / rel).is_file():
|
|
1622
|
+
hits.append((kind, rel))
|
|
1623
|
+
break
|
|
1624
|
+
if not hits:
|
|
1625
|
+
raise FileNotFoundError(
|
|
1626
|
+
"No wrapper GDS found (expected one of gds/user_project_wrapper.gds[.gz], "
|
|
1627
|
+
"gds/user_analog_project_wrapper.gds[.gz], "
|
|
1628
|
+
"gds/openframe_project_wrapper.gds[.gz])."
|
|
1629
|
+
)
|
|
1630
|
+
if len(hits) > 1:
|
|
1631
|
+
paths = ", ".join(h[1] for h in hits)
|
|
1632
|
+
raise ValueError(
|
|
1633
|
+
f"Multiple wrapper GDS layouts present ({paths}). Keep only one."
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
kind, wrapper_rel = hits[0]
|
|
1637
|
+
abs_wrapper = project_root / wrapper_rel
|
|
1638
|
+
results: List[Tuple[str, Path, int, str]] = [
|
|
1639
|
+
(wrapper_rel, abs_wrapper, abs_wrapper.stat().st_size, "wrapper")
|
|
1640
|
+
]
|
|
1641
|
+
|
|
1642
|
+
if kind != "openframe":
|
|
1643
|
+
ud = project_root / USER_DEFINES_REL
|
|
1644
|
+
if ud.is_file():
|
|
1645
|
+
results.append((USER_DEFINES_REL, ud, ud.stat().st_size, "aux"))
|
|
1646
|
+
|
|
1647
|
+
return results
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _push_https(project_root: Optional[str], project_name: Optional[str], dry_run: bool, submit: bool) -> None:
|
|
1651
|
+
"""Push project files to the platform by uploading directly to S3 over HTTPS.
|
|
1652
|
+
|
|
1653
|
+
Use case: customers whose network blocks BOTH SFTP (port 22) and
|
|
1654
|
+
GitHub, so they cannot use `cf push` or `cf push --remote`. They can
|
|
1655
|
+
still reach AWS S3 over HTTPS, which is what the backend hands them
|
|
1656
|
+
via pre-signed PUT URLs.
|
|
1657
|
+
|
|
1658
|
+
No Git involvement at all — the CLI hashes the local files, the
|
|
1659
|
+
backend returns pre-signed URLs, the CLI PUTs directly to S3, and
|
|
1660
|
+
the platform stages the objects onto EFS with the same synthesized
|
|
1661
|
+
.cf/project.json the --remote flow produces.
|
|
1662
|
+
"""
|
|
1663
|
+
cwd_root, cwd_project_name = get_project_json_from_cwd()
|
|
1664
|
+
if not project_root and cwd_root:
|
|
1665
|
+
project_root = cwd_root
|
|
1666
|
+
if not project_name and cwd_project_name:
|
|
1667
|
+
project_name = cwd_project_name
|
|
1668
|
+
if not project_root:
|
|
1669
|
+
console.print(
|
|
1670
|
+
"[red]No project root specified and no .cf/project.json found in current directory.[/red]"
|
|
1671
|
+
)
|
|
1672
|
+
console.print("Provide --project-root or run from a linked project.")
|
|
1673
|
+
raise click.Abort()
|
|
1674
|
+
project_root = str(Path(project_root).resolve())
|
|
1675
|
+
|
|
1676
|
+
platform_id = _load_project_platform_id(project_root)
|
|
1677
|
+
if not platform_id:
|
|
1678
|
+
console.print("[red]Project is not linked to the platform.[/red]")
|
|
1679
|
+
console.print("Run [bold]cf link[/bold] to connect this project, or [bold]cf init[/bold] to create a new one.")
|
|
1680
|
+
raise click.Abort()
|
|
1681
|
+
|
|
1682
|
+
config = load_user_config()
|
|
1683
|
+
if not config.get("api_key"):
|
|
1684
|
+
console.print("[red]Not logged in.[/red] Run [bold]cf login[/bold] before using --https.")
|
|
1685
|
+
raise click.Abort()
|
|
1686
|
+
|
|
1687
|
+
try:
|
|
1688
|
+
candidates = _collect_push_candidates(Path(project_root))
|
|
1689
|
+
except FileNotFoundError as e:
|
|
1690
|
+
console.print(f"[red]HTTPS push not ready:[/red] {e}")
|
|
1691
|
+
raise click.Abort()
|
|
1692
|
+
except ValueError as e:
|
|
1693
|
+
console.print(f"[red]HTTPS push not ready:[/red] {e}")
|
|
1694
|
+
raise click.Abort()
|
|
1695
|
+
|
|
1696
|
+
final_project_name = project_name or Path(project_root).name
|
|
1697
|
+
|
|
1698
|
+
total_bytes = sum(c[2] for c in candidates)
|
|
1699
|
+
mb = total_bytes / (1024 * 1024)
|
|
1700
|
+
console.print(
|
|
1701
|
+
f"[green]✓ Ready to upload[/green] [cyan]{len(candidates)}[/cyan] file(s) "
|
|
1702
|
+
f"([cyan]{mb:.1f} MiB[/cyan] total) to the platform over HTTPS."
|
|
1703
|
+
)
|
|
1704
|
+
|
|
1705
|
+
if dry_run:
|
|
1706
|
+
console.print("\n[bold]HTTPS push preview:[/bold]")
|
|
1707
|
+
console.print(f" Platform project: {platform_id}")
|
|
1708
|
+
console.print(f" Project name: {final_project_name}")
|
|
1709
|
+
for rel, abs_path, size, _ in candidates:
|
|
1710
|
+
console.print(f" • {rel} ({size / (1024 * 1024):.1f} MiB)")
|
|
1711
|
+
console.print(" (no files uploaded — dry run)")
|
|
1712
|
+
return
|
|
1713
|
+
|
|
1714
|
+
console.print("[dim]Hashing files locally…[/dim]")
|
|
1715
|
+
hashed: List[dict] = []
|
|
1716
|
+
for rel, abs_path, size, _ in candidates:
|
|
1717
|
+
digest = _sha256_file(abs_path)
|
|
1718
|
+
hashed.append({"rel_path": rel, "size": size, "sha256": digest})
|
|
1719
|
+
console.print(f" [dim]sha256[/dim] {digest[:16]}… {rel}")
|
|
1720
|
+
|
|
1721
|
+
console.print("Requesting upload slots from the platform…")
|
|
1722
|
+
try:
|
|
1723
|
+
init_resp = _api_post(
|
|
1724
|
+
f"/projects/{platform_id}/https-push/init",
|
|
1725
|
+
{"project_name": final_project_name, "files": hashed},
|
|
1726
|
+
timeout=60.0,
|
|
1727
|
+
)
|
|
1728
|
+
except SystemExit:
|
|
1729
|
+
raise click.Abort()
|
|
1730
|
+
|
|
1731
|
+
upload_id = init_resp.get("upload_id") or ""
|
|
1732
|
+
put_targets = {f["rel_path"]: f["put_url"] for f in (init_resp.get("files") or [])}
|
|
1733
|
+
if not upload_id or len(put_targets) != len(candidates):
|
|
1734
|
+
console.print("[red]✗ Platform did not return upload slots for every file.[/red]")
|
|
1735
|
+
raise click.Abort()
|
|
1736
|
+
|
|
1737
|
+
console.print(
|
|
1738
|
+
f"[dim]Upload id [bold]{upload_id[:8]}[/bold] — uploading to "
|
|
1739
|
+
f"{init_resp.get('bucket')} (HTTPS, {init_resp.get('expires_in', 3600)}s TTL)…[/dim]"
|
|
1740
|
+
)
|
|
1741
|
+
|
|
1742
|
+
# Per-file single PUT. We reuse one httpx client with a generous
|
|
1743
|
+
# timeout; the signed URL carries auth so no headers besides
|
|
1744
|
+
# x-amz-server-side-encryption are required.
|
|
1745
|
+
#
|
|
1746
|
+
# We stream the body with a generator instead of passing the file
|
|
1747
|
+
# directly so we can drive a rich progress bar (matches the UX of
|
|
1748
|
+
# the SFTP push path in utils.upload_with_progress). Content-Length
|
|
1749
|
+
# is set explicitly so S3 doesn't fall back to chunked encoding,
|
|
1750
|
+
# which pre-signed PUTs don't allow.
|
|
1751
|
+
import httpx
|
|
1752
|
+
from rich.progress import DownloadColumn, TransferSpeedColumn
|
|
1753
|
+
|
|
1754
|
+
put_timeout = httpx.Timeout(connect=10.0, read=1800.0, write=1800.0, pool=30.0)
|
|
1755
|
+
chunk_size = 1024 * 1024 # 1 MiB — big enough to keep overhead low, small enough for smooth bar updates
|
|
1756
|
+
with httpx.Client(timeout=put_timeout) as put_client:
|
|
1757
|
+
for rel, abs_path, size, _ in candidates:
|
|
1758
|
+
url = put_targets[rel]
|
|
1759
|
+
with Progress(
|
|
1760
|
+
TextColumn(" [cyan]↑[/cyan] [progress.description]{task.description}"),
|
|
1761
|
+
BarColumn(),
|
|
1762
|
+
TaskProgressColumn(),
|
|
1763
|
+
DownloadColumn(),
|
|
1764
|
+
TransferSpeedColumn(),
|
|
1765
|
+
TimeElapsedColumn(),
|
|
1766
|
+
console=console,
|
|
1767
|
+
transient=False,
|
|
1768
|
+
) as progress:
|
|
1769
|
+
task = progress.add_task(rel, total=max(size, 1))
|
|
1770
|
+
if size == 0:
|
|
1771
|
+
# httpx won't call our generator for an empty body;
|
|
1772
|
+
# advance the bar manually so the user sees it complete.
|
|
1773
|
+
progress.update(task, completed=1)
|
|
1774
|
+
|
|
1775
|
+
def _body_iter(path=abs_path, tid=task, prog=progress):
|
|
1776
|
+
with open(path, "rb") as fh:
|
|
1777
|
+
while True:
|
|
1778
|
+
buf = fh.read(chunk_size)
|
|
1779
|
+
if not buf:
|
|
1780
|
+
break
|
|
1781
|
+
prog.update(tid, advance=len(buf))
|
|
1782
|
+
yield buf
|
|
1783
|
+
|
|
1784
|
+
try:
|
|
1785
|
+
resp = put_client.put(
|
|
1786
|
+
url,
|
|
1787
|
+
content=_body_iter() if size > 0 else b"",
|
|
1788
|
+
headers={
|
|
1789
|
+
"Content-Type": "application/octet-stream",
|
|
1790
|
+
"Content-Length": str(size),
|
|
1791
|
+
"x-amz-server-side-encryption": "AES256",
|
|
1792
|
+
},
|
|
1793
|
+
)
|
|
1794
|
+
if resp.status_code >= 300:
|
|
1795
|
+
body = resp.text[:300]
|
|
1796
|
+
console.print(
|
|
1797
|
+
f"[red]✗ Upload of {rel} failed: HTTP {resp.status_code} — {body}[/red]"
|
|
1798
|
+
)
|
|
1799
|
+
raise click.Abort()
|
|
1800
|
+
except click.Abort:
|
|
1801
|
+
raise
|
|
1802
|
+
except Exception as e:
|
|
1803
|
+
console.print(f"[red]✗ Upload of {rel} failed: {type(e).__name__}: {e}[/red]")
|
|
1804
|
+
raise click.Abort()
|
|
1805
|
+
|
|
1806
|
+
console.print("[green]✓ All files uploaded. Asking platform to stage them on EFS…[/green]")
|
|
1807
|
+
try:
|
|
1808
|
+
complete_resp = _api_post(
|
|
1809
|
+
f"/projects/{platform_id}/https-push/complete",
|
|
1810
|
+
{"upload_id": upload_id, "project_name": final_project_name},
|
|
1811
|
+
timeout=600.0,
|
|
1812
|
+
)
|
|
1813
|
+
except SystemExit:
|
|
1814
|
+
raise click.Abort()
|
|
1815
|
+
|
|
1816
|
+
landed = complete_resp.get("landed") or []
|
|
1817
|
+
if landed:
|
|
1818
|
+
console.print("[green]✓ Files staged on the platform:[/green]")
|
|
1819
|
+
for rel in landed:
|
|
1820
|
+
console.print(f" • {rel}")
|
|
1821
|
+
else:
|
|
1822
|
+
console.print("[yellow]⚠ Platform accepted the request but did not report any landed files.[/yellow]")
|
|
1823
|
+
|
|
1824
|
+
try:
|
|
1825
|
+
with open(Path(project_root) / ".cf" / "project.json", "r") as f:
|
|
1826
|
+
pj = json.load(f)
|
|
1827
|
+
_api_put(
|
|
1828
|
+
f"/projects/{platform_id}",
|
|
1829
|
+
{"cli_project_json": _slim_project_json(pj), "cli_sync_source": "push"},
|
|
1830
|
+
timeout=60.0,
|
|
1831
|
+
)
|
|
1832
|
+
console.print("[green]✓ Platform project synced[/green]")
|
|
1833
|
+
except FileNotFoundError:
|
|
1834
|
+
# .cf/project.json is synthesized server-side now; we still PUT the
|
|
1835
|
+
# local copy if present for UX parity, but it's not required.
|
|
1836
|
+
pass
|
|
1837
|
+
except SystemExit:
|
|
1838
|
+
console.print("[yellow]⚠ HTTPS push succeeded but platform sync failed[/yellow]")
|
|
1839
|
+
except Exception:
|
|
1840
|
+
console.print("[yellow]⚠ Could not read project.json for platform sync[/yellow]")
|
|
1841
|
+
|
|
1842
|
+
if submit:
|
|
1843
|
+
try:
|
|
1844
|
+
_api_post(f"/projects/{platform_id}/submit", {})
|
|
1845
|
+
console.print("[green]✓ Project submitted for review[/green]")
|
|
1846
|
+
except SystemExit:
|
|
1847
|
+
console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]")
|
|
1848
|
+
|
|
1849
|
+
|
|
1575
1850
|
@main.command('push')
|
|
1576
1851
|
@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).')
|
|
1577
1852
|
@click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
|
|
@@ -1584,8 +1859,25 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r
|
|
|
1584
1859
|
@click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.')
|
|
1585
1860
|
@click.option('--submit', is_flag=True, help='Submit the project for review after upload.')
|
|
1586
1861
|
@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.')
|
|
1587
|
-
|
|
1588
|
-
|
|
1862
|
+
@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.')
|
|
1863
|
+
def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote, https_mode):
|
|
1864
|
+
"""Upload your project files to the ChipFoundry SFTP server.
|
|
1865
|
+
|
|
1866
|
+
Defaults to SFTP. Use --remote to push via the ChipFoundry GitHub App
|
|
1867
|
+
(HTTPS), or --https to upload directly to AWS S3 (also HTTPS) without
|
|
1868
|
+
needing Git. The two HTTPS modes are mutually exclusive.
|
|
1869
|
+
"""
|
|
1870
|
+
if remote and https_mode:
|
|
1871
|
+
console.print("[red]--remote and --https are mutually exclusive.[/red]")
|
|
1872
|
+
raise click.Abort()
|
|
1873
|
+
if https_mode:
|
|
1874
|
+
_push_https(
|
|
1875
|
+
project_root=project_root,
|
|
1876
|
+
project_name=project_name,
|
|
1877
|
+
dry_run=dry_run,
|
|
1878
|
+
submit=submit,
|
|
1879
|
+
)
|
|
1880
|
+
return
|
|
1589
1881
|
if remote:
|
|
1590
1882
|
_push_remote(
|
|
1591
1883
|
project_root=project_root,
|
|
@@ -4306,8 +4598,39 @@ def _api_client():
|
|
|
4306
4598
|
return client, api_url
|
|
4307
4599
|
|
|
4308
4600
|
|
|
4601
|
+
def _format_api_error(resp) -> str:
|
|
4602
|
+
"""Build a user-friendly error message from a platform error response.
|
|
4603
|
+
|
|
4604
|
+
FastAPI returns errors as `{"detail": "..."}` (or a list of validation
|
|
4605
|
+
errors). Surfacing that instead of the bare `Client error '409 Conflict'`
|
|
4606
|
+
lets users act on the real reason without tailing backend logs.
|
|
4607
|
+
"""
|
|
4608
|
+
status = resp.status_code
|
|
4609
|
+
try:
|
|
4610
|
+
body = resp.json()
|
|
4611
|
+
except Exception:
|
|
4612
|
+
snippet = (resp.text or "").strip()
|
|
4613
|
+
if snippet:
|
|
4614
|
+
return f"HTTP {status}: {snippet[:300]}"
|
|
4615
|
+
return f"HTTP {status}"
|
|
4616
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
4617
|
+
if isinstance(detail, str) and detail:
|
|
4618
|
+
return f"HTTP {status}: {detail}"
|
|
4619
|
+
if isinstance(detail, list) and detail:
|
|
4620
|
+
parts = []
|
|
4621
|
+
for item in detail:
|
|
4622
|
+
if isinstance(item, dict):
|
|
4623
|
+
loc = ".".join(str(p) for p in (item.get("loc") or [])[-2:])
|
|
4624
|
+
msg = item.get("msg") or ""
|
|
4625
|
+
parts.append(f"{loc}: {msg}" if loc else msg)
|
|
4626
|
+
if parts:
|
|
4627
|
+
return f"HTTP {status}: {'; '.join(parts)}"
|
|
4628
|
+
return f"HTTP {status}: {body}"
|
|
4629
|
+
|
|
4630
|
+
|
|
4309
4631
|
def _api_get(path: str):
|
|
4310
4632
|
"""Authenticated GET to the platform API. Returns parsed JSON or raises SystemExit."""
|
|
4633
|
+
import httpx as _httpx
|
|
4311
4634
|
client, _ = _api_client()
|
|
4312
4635
|
try:
|
|
4313
4636
|
resp = client.get(path)
|
|
@@ -4318,6 +4641,9 @@ def _api_get(path: str):
|
|
|
4318
4641
|
return resp.json()
|
|
4319
4642
|
except SystemExit:
|
|
4320
4643
|
raise
|
|
4644
|
+
except _httpx.HTTPStatusError as e:
|
|
4645
|
+
console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]")
|
|
4646
|
+
raise SystemExit(1)
|
|
4321
4647
|
except Exception as e:
|
|
4322
4648
|
console.print(f"[red]✗ API request failed: {e}[/red]")
|
|
4323
4649
|
raise SystemExit(1)
|
|
@@ -4332,6 +4658,7 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None):
|
|
|
4332
4658
|
Use a large value for long-running endpoints such as remote-push, which
|
|
4333
4659
|
waits for the platform to fetch files from GitHub and stage them on EFS.
|
|
4334
4660
|
"""
|
|
4661
|
+
import httpx as _httpx
|
|
4335
4662
|
client, _ = _api_client()
|
|
4336
4663
|
try:
|
|
4337
4664
|
kwargs = {"json": json_data}
|
|
@@ -4345,6 +4672,9 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None):
|
|
|
4345
4672
|
return resp.json()
|
|
4346
4673
|
except SystemExit:
|
|
4347
4674
|
raise
|
|
4675
|
+
except _httpx.HTTPStatusError as e:
|
|
4676
|
+
console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]")
|
|
4677
|
+
raise SystemExit(1)
|
|
4348
4678
|
except Exception as e:
|
|
4349
4679
|
console.print(f"[red]✗ API request failed: {e}[/red]")
|
|
4350
4680
|
raise SystemExit(1)
|
|
@@ -4357,6 +4687,7 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None):
|
|
|
4357
4687
|
|
|
4358
4688
|
`timeout` (seconds) overrides the client default for this request only.
|
|
4359
4689
|
"""
|
|
4690
|
+
import httpx as _httpx
|
|
4360
4691
|
client, _ = _api_client()
|
|
4361
4692
|
try:
|
|
4362
4693
|
kwargs = {"json": json_data}
|
|
@@ -4370,6 +4701,9 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None):
|
|
|
4370
4701
|
return resp.json()
|
|
4371
4702
|
except SystemExit:
|
|
4372
4703
|
raise
|
|
4704
|
+
except _httpx.HTTPStatusError as e:
|
|
4705
|
+
console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]")
|
|
4706
|
+
raise SystemExit(1)
|
|
4373
4707
|
except Exception as e:
|
|
4374
4708
|
console.print(f"[red]✗ API request failed: {e}[/red]")
|
|
4375
4709
|
raise SystemExit(1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|