chipfoundry-cli 2.3.13__tar.gz → 2.3.19__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.13 → chipfoundry_cli-2.3.19}/PKG-INFO +42 -15
- {chipfoundry_cli-2.3.13 → chipfoundry_cli-2.3.19}/README.md +41 -14
- {chipfoundry_cli-2.3.13 → chipfoundry_cli-2.3.19}/chipfoundry_cli/main.py +414 -119
- {chipfoundry_cli-2.3.13 → chipfoundry_cli-2.3.19}/chipfoundry_cli/remote_precheck_git.py +106 -0
- {chipfoundry_cli-2.3.13 → chipfoundry_cli-2.3.19}/chipfoundry_cli/utils.py +60 -1
- {chipfoundry_cli-2.3.13 → chipfoundry_cli-2.3.19}/pyproject.toml +1 -1
- {chipfoundry_cli-2.3.13 → chipfoundry_cli-2.3.19}/LICENSE +0 -0
- {chipfoundry_cli-2.3.13 → chipfoundry_cli-2.3.19}/chipfoundry_cli/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: chipfoundry-cli
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.19
|
|
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
|
|
@@ -256,14 +256,14 @@ cf logout
|
|
|
256
256
|
|
|
257
257
|
- Removes your stored API key from the local config
|
|
258
258
|
|
|
259
|
-
### Initialize a
|
|
259
|
+
### Initialize or Refresh a Project
|
|
260
260
|
|
|
261
261
|
```bash
|
|
262
|
-
cf init [--project-root DIRECTORY]
|
|
262
|
+
cf init [--project-root DIRECTORY] [--shuttle NAME_OR_ID] [--description TEXT]
|
|
263
263
|
```
|
|
264
264
|
|
|
265
265
|
> [!IMPORTANT]
|
|
266
|
-
>
|
|
266
|
+
> Run this first after cloning a repository. It is required before running:
|
|
267
267
|
> - `cf gpio-config`
|
|
268
268
|
> - `cf harden`
|
|
269
269
|
> - `cf precheck`
|
|
@@ -273,14 +273,17 @@ cf init [--project-root DIRECTORY]
|
|
|
273
273
|
> If you skip this step, other commands will show an error directing you to run `cf init` first.
|
|
274
274
|
|
|
275
275
|
**What it does:**
|
|
276
|
-
- **
|
|
277
|
-
- **
|
|
278
|
-
- **
|
|
279
|
-
-
|
|
280
|
-
-
|
|
276
|
+
- **Idempotent refresh**: Running `cf init` again on an already-linked project pulls in the current platform values, pre-fills prompts, and only PUTs the differences you confirm. The `platform_project_id` link is preserved.
|
|
277
|
+
- **Smart defaults**: Auto-detects project name from directory, project type from GDS files, and GitHub repo URL from your `origin` remote (HTTPS or SSH).
|
|
278
|
+
- **Interactive prompts**:
|
|
279
|
+
- When a stored value and a detected value match (or only one exists), press Enter to accept it.
|
|
280
|
+
- When they **differ** (e.g. a stale `github_repo_url` in `.cf/project.json` vs. your current `git remote`), the prompt shows both and Enter accepts the detected value (ground truth). Type `k` or `keep` to keep the current value instead, type a new value to override, or type `clear` to remove the field entirely.
|
|
281
|
+
- **Shuttle selection**: On first init, prompts to select an available shuttle (sorted by nearest deadline).
|
|
282
|
+
- **Platform registration**: Creates the project on the platform and links it automatically.
|
|
283
|
+
- Setting the GitHub repo URL enables `cf precheck --remote` and `cf push --remote`.
|
|
281
284
|
|
|
282
285
|
> [!NOTE]
|
|
283
|
-
> GDS hash is generated during `push`, not `init
|
|
286
|
+
> GDS hash is generated during `push`, not `init`.
|
|
284
287
|
|
|
285
288
|
### Link an Existing Project
|
|
286
289
|
|
|
@@ -590,17 +593,18 @@ cf verify counter_la --dry-run
|
|
|
590
593
|
cf push [OPTIONS]
|
|
591
594
|
```
|
|
592
595
|
|
|
593
|
-
**Prerequisites:** `cf login`, `cf link` (or `cf init`), `cf config`
|
|
596
|
+
**Prerequisites:** `cf login`, `cf link` (or `cf init`), `cf config` (SFTP mode only).
|
|
594
597
|
|
|
595
598
|
**Options:**
|
|
596
599
|
- `--project-root`: Specify project directory
|
|
597
|
-
- `--force-overwrite`: Overwrite existing files on SFTP
|
|
600
|
+
- `--force-overwrite`: Overwrite existing files on SFTP (SFTP mode only)
|
|
598
601
|
- `--submit`: Submit the project for review after upload
|
|
599
602
|
- `--dry-run`: Preview what would be uploaded
|
|
600
|
-
- `--sftp-username`: Override configured username
|
|
601
|
-
- `--sftp-key`: Override configured key path
|
|
603
|
+
- `--sftp-username`: Override configured username (SFTP mode only)
|
|
604
|
+
- `--sftp-key`: Override configured key path (SFTP mode only)
|
|
605
|
+
- `--remote`: HTTPS-only upload via the ChipFoundry GitHub App (no SFTP). Use this when port 22 is blocked by your corporate firewall.
|
|
602
606
|
|
|
603
|
-
**
|
|
607
|
+
**SFTP mode (default):**
|
|
604
608
|
1. Verifies the project is linked to the platform and you are logged in
|
|
605
609
|
2. Collects required project files
|
|
606
610
|
3. Auto-detects project type from GDS file
|
|
@@ -609,6 +613,29 @@ cf push [OPTIONS]
|
|
|
609
613
|
6. Syncs `project.json` data to the platform (GDS hash, version, project ID, slot number)
|
|
610
614
|
7. If `--submit` is used, submits the project for admin review
|
|
611
615
|
|
|
616
|
+
**Remote (HTTPS) mode — `cf push --remote`:**
|
|
617
|
+
|
|
618
|
+
Firewall friendly: only outbound HTTPS is needed. The CLI never uploads file
|
|
619
|
+
contents itself; instead, the platform fetches them from your GitHub repo
|
|
620
|
+
via the ChipFoundry GitHub App at your local HEAD commit.
|
|
621
|
+
|
|
622
|
+
Preconditions:
|
|
623
|
+
- Project has a GitHub repo URL (set via `cf init`, shown in the portal).
|
|
624
|
+
- The ChipFoundry GitHub App is installed on that repo (prompted in the portal).
|
|
625
|
+
- Your local `HEAD` has been pushed to `origin` on some branch (`git push`).
|
|
626
|
+
- Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (when not an openframe project), and `.cf/project.json` (when tracked).
|
|
627
|
+
|
|
628
|
+
What happens:
|
|
629
|
+
1. `cf push --remote` resolves your local HEAD SHA and checks it is reachable from a remote ref.
|
|
630
|
+
2. Platform uses its GitHub App installation token to read the three push-critical files at that commit and stages them into your SFTP landing zone.
|
|
631
|
+
3. `project.json` is synced to the platform, exactly like an SFTP push.
|
|
632
|
+
4. `--submit` submits for review on success.
|
|
633
|
+
|
|
634
|
+
> [!TIP]
|
|
635
|
+
> If `cf push` fails to reach `sftp.chipfoundry.io:22` from inside a corporate
|
|
636
|
+
> network, run `cf push --remote` instead. No VPN required — just outbound
|
|
637
|
+
> HTTPS and a GitHub repo linked to the project.
|
|
638
|
+
|
|
612
639
|
**GDS File Handling:**
|
|
613
640
|
- **Both compressed (`.gz`) and uncompressed (`.gds`) files are supported**
|
|
614
641
|
- **No automatic compression** - files are uploaded as-is
|
|
@@ -230,14 +230,14 @@ cf logout
|
|
|
230
230
|
|
|
231
231
|
- Removes your stored API key from the local config
|
|
232
232
|
|
|
233
|
-
### Initialize a
|
|
233
|
+
### Initialize or Refresh a Project
|
|
234
234
|
|
|
235
235
|
```bash
|
|
236
|
-
cf init [--project-root DIRECTORY]
|
|
236
|
+
cf init [--project-root DIRECTORY] [--shuttle NAME_OR_ID] [--description TEXT]
|
|
237
237
|
```
|
|
238
238
|
|
|
239
239
|
> [!IMPORTANT]
|
|
240
|
-
>
|
|
240
|
+
> Run this first after cloning a repository. It is required before running:
|
|
241
241
|
> - `cf gpio-config`
|
|
242
242
|
> - `cf harden`
|
|
243
243
|
> - `cf precheck`
|
|
@@ -247,14 +247,17 @@ cf init [--project-root DIRECTORY]
|
|
|
247
247
|
> If you skip this step, other commands will show an error directing you to run `cf init` first.
|
|
248
248
|
|
|
249
249
|
**What it does:**
|
|
250
|
-
- **
|
|
251
|
-
- **
|
|
252
|
-
- **
|
|
253
|
-
-
|
|
254
|
-
-
|
|
250
|
+
- **Idempotent refresh**: Running `cf init` again on an already-linked project pulls in the current platform values, pre-fills prompts, and only PUTs the differences you confirm. The `platform_project_id` link is preserved.
|
|
251
|
+
- **Smart defaults**: Auto-detects project name from directory, project type from GDS files, and GitHub repo URL from your `origin` remote (HTTPS or SSH).
|
|
252
|
+
- **Interactive prompts**:
|
|
253
|
+
- When a stored value and a detected value match (or only one exists), press Enter to accept it.
|
|
254
|
+
- When they **differ** (e.g. a stale `github_repo_url` in `.cf/project.json` vs. your current `git remote`), the prompt shows both and Enter accepts the detected value (ground truth). Type `k` or `keep` to keep the current value instead, type a new value to override, or type `clear` to remove the field entirely.
|
|
255
|
+
- **Shuttle selection**: On first init, prompts to select an available shuttle (sorted by nearest deadline).
|
|
256
|
+
- **Platform registration**: Creates the project on the platform and links it automatically.
|
|
257
|
+
- Setting the GitHub repo URL enables `cf precheck --remote` and `cf push --remote`.
|
|
255
258
|
|
|
256
259
|
> [!NOTE]
|
|
257
|
-
> GDS hash is generated during `push`, not `init
|
|
260
|
+
> GDS hash is generated during `push`, not `init`.
|
|
258
261
|
|
|
259
262
|
### Link an Existing Project
|
|
260
263
|
|
|
@@ -564,17 +567,18 @@ cf verify counter_la --dry-run
|
|
|
564
567
|
cf push [OPTIONS]
|
|
565
568
|
```
|
|
566
569
|
|
|
567
|
-
**Prerequisites:** `cf login`, `cf link` (or `cf init`), `cf config`
|
|
570
|
+
**Prerequisites:** `cf login`, `cf link` (or `cf init`), `cf config` (SFTP mode only).
|
|
568
571
|
|
|
569
572
|
**Options:**
|
|
570
573
|
- `--project-root`: Specify project directory
|
|
571
|
-
- `--force-overwrite`: Overwrite existing files on SFTP
|
|
574
|
+
- `--force-overwrite`: Overwrite existing files on SFTP (SFTP mode only)
|
|
572
575
|
- `--submit`: Submit the project for review after upload
|
|
573
576
|
- `--dry-run`: Preview what would be uploaded
|
|
574
|
-
- `--sftp-username`: Override configured username
|
|
575
|
-
- `--sftp-key`: Override configured key path
|
|
577
|
+
- `--sftp-username`: Override configured username (SFTP mode only)
|
|
578
|
+
- `--sftp-key`: Override configured key path (SFTP mode only)
|
|
579
|
+
- `--remote`: HTTPS-only upload via the ChipFoundry GitHub App (no SFTP). Use this when port 22 is blocked by your corporate firewall.
|
|
576
580
|
|
|
577
|
-
**
|
|
581
|
+
**SFTP mode (default):**
|
|
578
582
|
1. Verifies the project is linked to the platform and you are logged in
|
|
579
583
|
2. Collects required project files
|
|
580
584
|
3. Auto-detects project type from GDS file
|
|
@@ -583,6 +587,29 @@ cf push [OPTIONS]
|
|
|
583
587
|
6. Syncs `project.json` data to the platform (GDS hash, version, project ID, slot number)
|
|
584
588
|
7. If `--submit` is used, submits the project for admin review
|
|
585
589
|
|
|
590
|
+
**Remote (HTTPS) mode — `cf push --remote`:**
|
|
591
|
+
|
|
592
|
+
Firewall friendly: only outbound HTTPS is needed. The CLI never uploads file
|
|
593
|
+
contents itself; instead, the platform fetches them from your GitHub repo
|
|
594
|
+
via the ChipFoundry GitHub App at your local HEAD commit.
|
|
595
|
+
|
|
596
|
+
Preconditions:
|
|
597
|
+
- Project has a GitHub repo URL (set via `cf init`, shown in the portal).
|
|
598
|
+
- The ChipFoundry GitHub App is installed on that repo (prompted in the portal).
|
|
599
|
+
- Your local `HEAD` has been pushed to `origin` on some branch (`git push`).
|
|
600
|
+
- Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (when not an openframe project), and `.cf/project.json` (when tracked).
|
|
601
|
+
|
|
602
|
+
What happens:
|
|
603
|
+
1. `cf push --remote` resolves your local HEAD SHA and checks it is reachable from a remote ref.
|
|
604
|
+
2. Platform uses its GitHub App installation token to read the three push-critical files at that commit and stages them into your SFTP landing zone.
|
|
605
|
+
3. `project.json` is synced to the platform, exactly like an SFTP push.
|
|
606
|
+
4. `--submit` submits for review on success.
|
|
607
|
+
|
|
608
|
+
> [!TIP]
|
|
609
|
+
> If `cf push` fails to reach `sftp.chipfoundry.io:22` from inside a corporate
|
|
610
|
+
> network, run `cf push --remote` instead. No VPN required — just outbound
|
|
611
|
+
> HTTPS and a GitHub repo linked to the project.
|
|
612
|
+
|
|
586
613
|
**GDS File Handling:**
|
|
587
614
|
- **Both compressed (`.gz`) and uncompressed (`.gds`) files are supported**
|
|
588
615
|
- **No automatic compression** - files are uploaded as-is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import click
|
|
2
2
|
import getpass
|
|
3
|
+
from typing import Optional, List
|
|
3
4
|
from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo
|
|
4
5
|
from chipfoundry_cli.utils import (
|
|
5
6
|
collect_project_files, ensure_cf_directory, update_or_create_project_json,
|
|
@@ -8,7 +9,8 @@ from chipfoundry_cli.utils import (
|
|
|
8
9
|
open_html_in_browser, download_with_progress, update_repo_files,
|
|
9
10
|
fetch_versions_from_upstream, parse_user_defines_v, update_user_defines_v,
|
|
10
11
|
get_gpio_config_from_project_json, save_gpio_config_to_project_json,
|
|
11
|
-
GPIO_MODES, GPIO_MODE_DESCRIPTIONS, GPIO_HEX_TO_MODE
|
|
12
|
+
GPIO_MODES, GPIO_MODE_DESCRIPTIONS, GPIO_HEX_TO_MODE,
|
|
13
|
+
detect_github_repo_url, get_head_commit_sha,
|
|
12
14
|
)
|
|
13
15
|
import os
|
|
14
16
|
from pathlib import Path
|
|
@@ -176,21 +178,40 @@ def main():
|
|
|
176
178
|
|
|
177
179
|
@main.command('config')
|
|
178
180
|
def config_cmd():
|
|
179
|
-
"""Configure
|
|
180
|
-
console.print("[bold cyan]ChipFoundry CLI
|
|
181
|
-
|
|
182
|
-
key_path = console.input("Enter path to your SFTP private key (leave blank for ~/.ssh/chipfoundry-key): ").strip()
|
|
181
|
+
"""Configure a custom SSH private key path for SFTP access."""
|
|
182
|
+
console.print("[bold cyan]ChipFoundry CLI Configuration[/bold cyan]")
|
|
183
|
+
key_path = console.input("Enter path to your SSH private key (leave blank for ~/.ssh/chipfoundry-key): ").strip()
|
|
183
184
|
if not key_path:
|
|
184
185
|
key_path = os.path.expanduser('~/.ssh/chipfoundry-key')
|
|
185
186
|
else:
|
|
186
187
|
key_path = os.path.abspath(os.path.expanduser(key_path))
|
|
187
|
-
config =
|
|
188
|
-
|
|
189
|
-
"sftp_key": key_path,
|
|
190
|
-
}
|
|
188
|
+
config = load_user_config()
|
|
189
|
+
config["sftp_key"] = key_path
|
|
191
190
|
save_user_config(config)
|
|
192
191
|
console.print(f"[green]Configuration saved to {get_config_path()}[/green]")
|
|
193
192
|
|
|
193
|
+
def _try_register_ssh_key(public_key: str) -> bool:
|
|
194
|
+
"""Attempt to register the SSH public key on the user's platform profile.
|
|
195
|
+
|
|
196
|
+
Returns True if the key was registered successfully, False otherwise.
|
|
197
|
+
"""
|
|
198
|
+
config = load_user_config()
|
|
199
|
+
if not config.get("api_key"):
|
|
200
|
+
return False
|
|
201
|
+
try:
|
|
202
|
+
_api_put("/users/me", {"ssh_public_key": public_key})
|
|
203
|
+
return True
|
|
204
|
+
except SystemExit:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _print_manual_key_instructions():
|
|
209
|
+
"""Print fallback instructions when auto-registration is not available."""
|
|
210
|
+
console.print("[bold cyan]To register this key:[/bold cyan]")
|
|
211
|
+
console.print(" Run [bold]cf login[/bold] first, then [bold]cf keygen --overwrite[/bold] to auto-register.")
|
|
212
|
+
console.print(" Or paste the public key at [bold]https://platform.chipfoundry.io/ssh-key[/bold]")
|
|
213
|
+
|
|
214
|
+
|
|
194
215
|
@main.command('keygen')
|
|
195
216
|
@click.option('--overwrite', is_flag=True, help='Overwrite existing key if it already exists.')
|
|
196
217
|
def keygen(overwrite):
|
|
@@ -211,11 +232,10 @@ def keygen(overwrite):
|
|
|
211
232
|
public_key = f.read().strip()
|
|
212
233
|
print(f"{public_key}", end="")
|
|
213
234
|
print("")
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
console.print("4. Use 'cf config' to configure your SFTP credentials")
|
|
235
|
+
if _try_register_ssh_key(public_key):
|
|
236
|
+
console.print("[green]✓ Key registered on your ChipFoundry profile. SFTP access is ready.[/green]")
|
|
237
|
+
else:
|
|
238
|
+
_print_manual_key_instructions()
|
|
219
239
|
return
|
|
220
240
|
else:
|
|
221
241
|
console.print(f"[yellow]Overwriting existing key at {private_key_path}[/yellow]")
|
|
@@ -229,7 +249,6 @@ def keygen(overwrite):
|
|
|
229
249
|
console.print("[cyan]Generating new RSA SSH key for ChipFoundry...[/cyan]")
|
|
230
250
|
|
|
231
251
|
try:
|
|
232
|
-
# Use ssh-keygen to generate the key
|
|
233
252
|
cmd = [
|
|
234
253
|
'ssh-keygen',
|
|
235
254
|
'-t', 'rsa',
|
|
@@ -256,12 +275,10 @@ def keygen(overwrite):
|
|
|
256
275
|
print(f"{public_key}", end="")
|
|
257
276
|
print("")
|
|
258
277
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
console.print("3. Wait for account approval")
|
|
264
|
-
console.print("4. Use 'cf config' to configure your SFTP credentials")
|
|
278
|
+
if _try_register_ssh_key(public_key):
|
|
279
|
+
console.print("[green]✓ Key registered on your ChipFoundry profile. SFTP access is ready.[/green]")
|
|
280
|
+
else:
|
|
281
|
+
_print_manual_key_instructions()
|
|
265
282
|
|
|
266
283
|
except subprocess.CalledProcessError as e:
|
|
267
284
|
console.print(f"[red]Failed to generate SSH key: {e}[/red]")
|
|
@@ -289,37 +306,89 @@ def keyview():
|
|
|
289
306
|
public_key = f.read().strip()
|
|
290
307
|
print(f"{public_key}")
|
|
291
308
|
print("")
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
309
|
+
_print_manual_key_instructions()
|
|
310
|
+
|
|
311
|
+
def _prompt_with_default(label: str, current: Optional[str], detected: Optional[str] = None) -> Optional[str]:
|
|
312
|
+
"""Interactive prompt with sensible defaults for current/detected values.
|
|
313
|
+
|
|
314
|
+
Behavior:
|
|
315
|
+
- No current, no detected: Enter leaves the value unset (None).
|
|
316
|
+
- Only current: Enter keeps current.
|
|
317
|
+
- Only detected: Enter accepts detected.
|
|
318
|
+
- Current == detected: Enter accepts the (single) value.
|
|
319
|
+
- Current != detected: Enter accepts `detected` (ground truth, e.g. git
|
|
320
|
+
remote). Type `k` or `keep` to keep current.
|
|
321
|
+
Any typed value becomes the new value. `clear` (case-insensitive) explicitly
|
|
322
|
+
removes the value (returns None).
|
|
323
|
+
"""
|
|
324
|
+
normalized_current = current.strip() if isinstance(current, str) and current.strip() else None
|
|
325
|
+
normalized_detected = detected.strip() if isinstance(detected, str) and detected.strip() else None
|
|
326
|
+
conflict = (
|
|
327
|
+
normalized_current is not None
|
|
328
|
+
and normalized_detected is not None
|
|
329
|
+
and normalized_current != normalized_detected
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if conflict:
|
|
333
|
+
effective_default = normalized_detected
|
|
334
|
+
elif normalized_detected is not None:
|
|
335
|
+
effective_default = normalized_detected
|
|
336
|
+
else:
|
|
337
|
+
effective_default = normalized_current
|
|
338
|
+
|
|
339
|
+
console.print(f"[bold]{label}[/bold]")
|
|
340
|
+
if normalized_current:
|
|
341
|
+
console.print(f" current: [cyan]{normalized_current}[/cyan]")
|
|
342
|
+
if normalized_detected and normalized_detected != normalized_current:
|
|
343
|
+
console.print(f" detected: [cyan]{normalized_detected}[/cyan]")
|
|
344
|
+
|
|
345
|
+
if conflict:
|
|
346
|
+
hint = "enter=use detected, k=keep current, clear=remove, or type new value"
|
|
347
|
+
elif effective_default:
|
|
348
|
+
hint = "enter=accept, clear=remove, or type new value"
|
|
349
|
+
else:
|
|
350
|
+
hint = "enter=skip, or type value"
|
|
351
|
+
|
|
352
|
+
raw = console.input(f" [dim]{hint}[/dim]: ").strip()
|
|
353
|
+
if raw == "":
|
|
354
|
+
return effective_default
|
|
355
|
+
lowered = raw.lower()
|
|
356
|
+
if lowered == "clear":
|
|
357
|
+
return None
|
|
358
|
+
if conflict and lowered in ("k", "keep"):
|
|
359
|
+
return normalized_current
|
|
360
|
+
return raw
|
|
361
|
+
|
|
297
362
|
|
|
298
363
|
@main.command('init')
|
|
299
|
-
@click.option('--project-root', required=False, type=click.Path(file_okay=False), help='
|
|
364
|
+
@click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Project directory (defaults to current directory).')
|
|
300
365
|
@click.option('--shuttle', default=None, help='Shuttle name or ID to associate with the project.')
|
|
301
|
-
@click.option('--description', default=None, help='Project description.')
|
|
366
|
+
@click.option('--description', default=None, help='Project description (skips description prompt).')
|
|
302
367
|
def init(project_root, shuttle, description):
|
|
303
|
-
"""Initialize
|
|
368
|
+
"""Initialize or refresh the local ChipFoundry project configuration.
|
|
369
|
+
|
|
370
|
+
Running `cf init` is idempotent: if the project is already linked to the
|
|
371
|
+
platform, existing values are pulled in, auto-detected values from the
|
|
372
|
+
workspace (e.g. GitHub remote) are offered, and only the changes you
|
|
373
|
+
confirm are pushed back via PUT. The `platform_project_id` link is
|
|
374
|
+
preserved — use `cf unlink` to disconnect.
|
|
375
|
+
"""
|
|
304
376
|
if not project_root:
|
|
305
377
|
project_root = os.getcwd()
|
|
378
|
+
project_root = str(Path(project_root).resolve())
|
|
306
379
|
cf_dir = Path(project_root) / '.cf'
|
|
307
380
|
cf_dir.mkdir(parents=True, exist_ok=True)
|
|
308
381
|
project_json_path = cf_dir / 'project.json'
|
|
309
382
|
|
|
310
|
-
|
|
383
|
+
local_data: dict = {}
|
|
311
384
|
if project_json_path.exists():
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
console.print(f"[
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
overwrite = console.input(f"[yellow]project.json already exists at {project_json_path}. Overwrite? (y/N): [/yellow]").strip().lower()
|
|
320
|
-
if overwrite != 'y':
|
|
321
|
-
console.print("[red]Aborted project initialization.[/red]")
|
|
322
|
-
return
|
|
385
|
+
try:
|
|
386
|
+
with open(project_json_path) as f:
|
|
387
|
+
local_data = json.load(f)
|
|
388
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
389
|
+
console.print(f"[red]✗ Could not read existing {project_json_path}: {e}[/red]")
|
|
390
|
+
raise click.Abort()
|
|
391
|
+
local_proj = local_data.get('project', {}) if isinstance(local_data, dict) else {}
|
|
323
392
|
|
|
324
393
|
config = load_user_config()
|
|
325
394
|
username = config.get("sftp_username")
|
|
@@ -336,92 +405,159 @@ def init(project_root, shuttle, description):
|
|
|
336
405
|
console.print("[bold red]No SFTP account linked to your platform account. Please run 'cf login' first.[/bold red]")
|
|
337
406
|
raise click.Abort()
|
|
338
407
|
|
|
408
|
+
api_key = config.get('api_key')
|
|
409
|
+
platform_id = local_proj.get('platform_project_id')
|
|
410
|
+
platform_proj: Optional[dict] = None
|
|
411
|
+
if platform_id and api_key:
|
|
412
|
+
try:
|
|
413
|
+
platform_proj = _api_get(f"/projects/{platform_id}")
|
|
414
|
+
except SystemExit:
|
|
415
|
+
console.print(f"[yellow]Could not fetch linked platform project {platform_id}; continuing with local data only.[/yellow]")
|
|
416
|
+
platform_proj = None
|
|
417
|
+
|
|
418
|
+
mode = "refresh" if platform_proj else "create"
|
|
419
|
+
console.print(f"[bold cyan]cf init[/bold cyan] — {'refreshing linked project' if mode == 'refresh' else 'initializing new project'}")
|
|
420
|
+
|
|
421
|
+
def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str]:
|
|
422
|
+
"""Prefer platform value when linked, else local value."""
|
|
423
|
+
kp = key_platform or key_local
|
|
424
|
+
if platform_proj is not None and platform_proj.get(kp) not in (None, ""):
|
|
425
|
+
return platform_proj.get(kp)
|
|
426
|
+
val = local_proj.get(key_local)
|
|
427
|
+
return val if val not in (None, "") else None
|
|
428
|
+
|
|
429
|
+
current_name = _merged('name')
|
|
430
|
+
default_name = current_name or Path(project_root).name
|
|
431
|
+
detected_type = None
|
|
339
432
|
gds_dir = Path(project_root) / 'gds'
|
|
340
|
-
gds_type = None
|
|
341
433
|
for gds_name, gtype in GDS_TYPE_MAP.items():
|
|
342
434
|
if (gds_dir / gds_name).exists():
|
|
343
|
-
|
|
435
|
+
detected_type = gtype
|
|
344
436
|
break
|
|
437
|
+
current_type = local_proj.get('type') or (platform_proj or {}).get('design_type')
|
|
438
|
+
current_desc = _merged('description')
|
|
439
|
+
current_github = (platform_proj or {}).get('github_repo_url') if platform_proj else local_proj.get('github_repo_url')
|
|
440
|
+
detected_github = detect_github_repo_url(project_root)
|
|
441
|
+
|
|
442
|
+
name = _prompt_with_default("Project name", current_name, default_name) or default_name
|
|
443
|
+
project_type = _prompt_with_default(
|
|
444
|
+
"Project type (digital/analog/openframe)", current_type, detected_type
|
|
445
|
+
)
|
|
446
|
+
if not project_type:
|
|
447
|
+
console.print("[red]Project type is required.[/red]")
|
|
448
|
+
raise click.Abort()
|
|
345
449
|
|
|
346
|
-
|
|
347
|
-
|
|
450
|
+
if description is not None:
|
|
451
|
+
description_val: Optional[str] = description or None
|
|
452
|
+
else:
|
|
453
|
+
description_val = _prompt_with_default("Description", current_desc, None)
|
|
454
|
+
|
|
455
|
+
github_repo_url = _prompt_with_default("GitHub repo URL", current_github, detected_github)
|
|
348
456
|
|
|
349
|
-
if
|
|
350
|
-
|
|
457
|
+
data = local_data if isinstance(local_data, dict) else {}
|
|
458
|
+
proj = data.setdefault('project', {})
|
|
459
|
+
proj['name'] = name
|
|
460
|
+
proj['type'] = project_type
|
|
461
|
+
proj['user'] = username
|
|
462
|
+
proj.setdefault('version', local_proj.get('version') or "1")
|
|
463
|
+
proj.setdefault('user_project_wrapper_hash', local_proj.get('user_project_wrapper_hash', ""))
|
|
464
|
+
proj.setdefault('submission_state', local_proj.get('submission_state', "Draft"))
|
|
465
|
+
if github_repo_url:
|
|
466
|
+
proj['github_repo_url'] = github_repo_url
|
|
351
467
|
else:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
version = "1"
|
|
355
|
-
data = {
|
|
356
|
-
"project": {
|
|
357
|
-
"name": name,
|
|
358
|
-
"type": project_type,
|
|
359
|
-
"user": username,
|
|
360
|
-
"version": version,
|
|
361
|
-
"user_project_wrapper_hash": "",
|
|
362
|
-
"submission_state": "Draft"
|
|
363
|
-
}
|
|
364
|
-
}
|
|
468
|
+
proj.pop('github_repo_url', None)
|
|
365
469
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
470
|
+
if not api_key:
|
|
471
|
+
with open(project_json_path, 'w') as f:
|
|
472
|
+
json.dump(data, f, indent=2)
|
|
473
|
+
console.print(f"[green]✓ Saved local project config at {project_json_path}[/green]")
|
|
474
|
+
console.print("[dim]Tip: Run [bold]cf login[/bold] to connect this project to the platform.[/dim]")
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
if platform_proj:
|
|
478
|
+
update_payload: dict = {}
|
|
479
|
+
if name != platform_proj.get('name'):
|
|
480
|
+
update_payload['name'] = name
|
|
481
|
+
if description_val != (platform_proj.get('description') or None):
|
|
482
|
+
update_payload['description'] = description_val or ""
|
|
483
|
+
if project_type != platform_proj.get('design_type'):
|
|
484
|
+
update_payload['design_type'] = project_type
|
|
485
|
+
if (github_repo_url or None) != (platform_proj.get('github_repo_url') or None):
|
|
486
|
+
update_payload['github_repo_url'] = github_repo_url or ""
|
|
487
|
+
|
|
488
|
+
if update_payload:
|
|
370
489
|
try:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
console.print("\n[bold]Available shuttles:[/bold]")
|
|
375
|
-
for i, s in enumerate(shuttles, 1):
|
|
376
|
-
deadline = s.get('tapeout_date', '')
|
|
377
|
-
console.print(f" [cyan]{i}[/cyan]. {s['name']}{f' — submission deadline {deadline}' if deadline else ''}")
|
|
378
|
-
console.print(f" [cyan]{len(shuttles) + 1}[/cyan]. Skip — choose later")
|
|
379
|
-
choice = console.input("\nSelect shuttle: ").strip()
|
|
380
|
-
try:
|
|
381
|
-
idx = int(choice) - 1
|
|
382
|
-
if 0 <= idx < len(shuttles):
|
|
383
|
-
shuttle_id = shuttles[idx]['id']
|
|
384
|
-
except (ValueError, IndexError):
|
|
385
|
-
pass
|
|
490
|
+
updated = _api_put(f"/projects/{platform_id}", update_payload)
|
|
491
|
+
platform_proj = updated
|
|
492
|
+
console.print(f"[green]✓ Updated platform project[/green] ({', '.join(update_payload.keys())})")
|
|
386
493
|
except SystemExit:
|
|
387
|
-
console.print("[
|
|
494
|
+
console.print("[yellow]Platform update failed — local changes saved.[/yellow]")
|
|
388
495
|
else:
|
|
389
|
-
|
|
496
|
+
console.print("[dim]No platform changes needed.[/dim]")
|
|
390
497
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
if
|
|
398
|
-
|
|
498
|
+
proj['platform_project_id'] = platform_id
|
|
499
|
+
with open(project_json_path, 'w') as f:
|
|
500
|
+
json.dump(data, f, indent=2)
|
|
501
|
+
portal_url = _get_portal_url()
|
|
502
|
+
console.print(f" Name: {name}")
|
|
503
|
+
console.print(f" ID: {platform_id}")
|
|
504
|
+
if github_repo_url:
|
|
505
|
+
console.print(f" GitHub: {github_repo_url}")
|
|
506
|
+
console.print(f" Portal: {portal_url}/projects/{platform_id}")
|
|
507
|
+
return
|
|
399
508
|
|
|
509
|
+
shuttle_id = shuttle
|
|
510
|
+
if not shuttle_id:
|
|
400
511
|
try:
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return
|
|
512
|
+
shuttles = _api_get("/shuttles/available")
|
|
513
|
+
if shuttles:
|
|
514
|
+
shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31'))
|
|
515
|
+
console.print("\n[bold]Available shuttles:[/bold]")
|
|
516
|
+
for i, s in enumerate(shuttles, 1):
|
|
517
|
+
deadline = s.get('tapeout_date', '')
|
|
518
|
+
console.print(f" [cyan]{i}[/cyan]. {s['name']}{f' — submission deadline {deadline}' if deadline else ''}")
|
|
519
|
+
console.print(f" [cyan]{len(shuttles) + 1}[/cyan]. Skip — choose later")
|
|
520
|
+
choice = console.input("\nSelect shuttle: ").strip()
|
|
521
|
+
try:
|
|
522
|
+
idx = int(choice) - 1
|
|
523
|
+
if 0 <= idx < len(shuttles):
|
|
524
|
+
shuttle_id = shuttles[idx]['id']
|
|
525
|
+
except (ValueError, IndexError):
|
|
526
|
+
pass
|
|
417
527
|
except SystemExit:
|
|
418
|
-
console.print("[
|
|
419
|
-
else:
|
|
420
|
-
console.print("[dim]Tip: Run [bold]cf login[/bold] to connect this project to the platform.[/dim]")
|
|
528
|
+
console.print("[dim]Could not fetch shuttles — continuing without shuttle selection.[/dim]")
|
|
421
529
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
530
|
+
create_data: dict = {
|
|
531
|
+
"name": name,
|
|
532
|
+
"description": description_val or "",
|
|
533
|
+
"design_type": project_type,
|
|
534
|
+
"registration_source": "cli",
|
|
535
|
+
}
|
|
536
|
+
if shuttle_id:
|
|
537
|
+
create_data["shuttle_id"] = str(shuttle_id)
|
|
538
|
+
if github_repo_url:
|
|
539
|
+
create_data["github_repo_url"] = github_repo_url
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
project_resp = _api_post("/projects", create_data)
|
|
543
|
+
new_id = project_resp.get('id')
|
|
544
|
+
proj['platform_project_id'] = new_id
|
|
545
|
+
with open(project_json_path, 'w') as f:
|
|
546
|
+
json.dump(data, f, indent=2)
|
|
547
|
+
portal_url = _get_portal_url()
|
|
548
|
+
console.print(f"\n[green]✓ Project created on platform[/green]")
|
|
549
|
+
console.print(f" Name: {name}")
|
|
550
|
+
console.print(f" ID: {new_id}")
|
|
551
|
+
if project_resp.get('shuttle_name'):
|
|
552
|
+
console.print(f" Shuttle: {project_resp['shuttle_name']}")
|
|
553
|
+
if github_repo_url:
|
|
554
|
+
console.print(f" GitHub: {github_repo_url}")
|
|
555
|
+
console.print(f" Status: Draft")
|
|
556
|
+
console.print(f" Portal: {portal_url}/projects/{new_id}")
|
|
557
|
+
except SystemExit:
|
|
558
|
+
console.print("[yellow]Platform project creation failed — saving local project only.[/yellow]")
|
|
559
|
+
with open(project_json_path, 'w') as f:
|
|
560
|
+
json.dump(data, f, indent=2)
|
|
425
561
|
|
|
426
562
|
@main.command('gpio-config')
|
|
427
563
|
@click.option('--project-root', required=False, type=click.Path(exists=True, file_okay=False), help='Path to the project directory (defaults to current directory).')
|
|
@@ -1285,6 +1421,142 @@ def gpio_config(project_root, view):
|
|
|
1285
1421
|
console.print(f"[red]Error updating user_defines.v: {e}[/red]")
|
|
1286
1422
|
|
|
1287
1423
|
|
|
1424
|
+
def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_run: bool, submit: bool) -> None:
|
|
1425
|
+
"""Push project files to the platform via the ChipFoundry GitHub App (HTTPS only).
|
|
1426
|
+
|
|
1427
|
+
Preconditions enforced here:
|
|
1428
|
+
- Project is linked (`platform_project_id` in .cf/project.json).
|
|
1429
|
+
- Logged in (api key).
|
|
1430
|
+
- Local git HEAD is reachable from a remote ref on origin and the files the
|
|
1431
|
+
platform will fetch (wrapper GDS, user_defines.v when required, .cf/project.json
|
|
1432
|
+
when tracked) are clean at HEAD.
|
|
1433
|
+
|
|
1434
|
+
On success the backend:
|
|
1435
|
+
1. Resolves the GitHub App installation for the project's `github_repo_url`.
|
|
1436
|
+
2. Selects the three push-critical blobs at `commit_sha` and asks the
|
|
1437
|
+
SFTP home-dir Lambda to stage them into the customer's EFS landing zone.
|
|
1438
|
+
3. Syncs project.json (same as SFTP push) and, if requested, submits for review.
|
|
1439
|
+
"""
|
|
1440
|
+
from chipfoundry_cli.remote_precheck_git import RemotePushGitError, verify_push_repo
|
|
1441
|
+
|
|
1442
|
+
cwd_root, cwd_project_name = get_project_json_from_cwd()
|
|
1443
|
+
if not project_root and cwd_root:
|
|
1444
|
+
project_root = cwd_root
|
|
1445
|
+
if not project_name and cwd_project_name:
|
|
1446
|
+
project_name = cwd_project_name
|
|
1447
|
+
if not project_root:
|
|
1448
|
+
console.print(
|
|
1449
|
+
"[red]No project root specified and no .cf/project.json found in current directory.[/red]"
|
|
1450
|
+
)
|
|
1451
|
+
console.print("Provide --project-root or run from a linked project.")
|
|
1452
|
+
raise click.Abort()
|
|
1453
|
+
project_root = str(Path(project_root).resolve())
|
|
1454
|
+
|
|
1455
|
+
platform_id = _load_project_platform_id(project_root)
|
|
1456
|
+
if not platform_id:
|
|
1457
|
+
console.print("[red]Project is not linked to the platform.[/red]")
|
|
1458
|
+
console.print("Run [bold]cf link[/bold] to connect this project, or [bold]cf init[/bold] to create a new one.")
|
|
1459
|
+
raise click.Abort()
|
|
1460
|
+
|
|
1461
|
+
config = load_user_config()
|
|
1462
|
+
if not config.get("api_key"):
|
|
1463
|
+
console.print("[red]Not logged in.[/red] Run [bold]cf login[/bold] before using --remote.")
|
|
1464
|
+
raise click.Abort()
|
|
1465
|
+
|
|
1466
|
+
try:
|
|
1467
|
+
head_sha, remote_ref = verify_push_repo(Path(project_root))
|
|
1468
|
+
except RemotePushGitError as e:
|
|
1469
|
+
console.print(f"[red]Remote push not ready:[/red] {e}")
|
|
1470
|
+
raise click.Abort()
|
|
1471
|
+
except Exception as e: # defensive: never leak a raw traceback here
|
|
1472
|
+
console.print(f"[red]Remote push could not verify the repo:[/red] {type(e).__name__}: {e}")
|
|
1473
|
+
raise click.Abort()
|
|
1474
|
+
|
|
1475
|
+
console.print(
|
|
1476
|
+
f"[green]✓ Local checkout ready[/green] (HEAD [cyan]{head_sha[:7]}[/cyan] is on [cyan]{remote_ref}[/cyan])"
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
try:
|
|
1480
|
+
project = _api_get(f"/projects/{platform_id}")
|
|
1481
|
+
except SystemExit:
|
|
1482
|
+
raise click.Abort()
|
|
1483
|
+
|
|
1484
|
+
github_repo_url = (project.get("github_repo_url") or "").strip()
|
|
1485
|
+
if not github_repo_url:
|
|
1486
|
+
console.print(
|
|
1487
|
+
"[red]This project has no GitHub repo URL configured.[/red]\n"
|
|
1488
|
+
"Run [bold]cf init[/bold] and set the GitHub repo URL, or update it in the portal."
|
|
1489
|
+
)
|
|
1490
|
+
raise click.Abort()
|
|
1491
|
+
if not project.get("remote_precheck_github_ready"):
|
|
1492
|
+
install_url = (project.get("remote_precheck_github_app_install_url") or "").strip()
|
|
1493
|
+
console.print(
|
|
1494
|
+
"[red]The ChipFoundry GitHub App is not installed on this repository[/red] "
|
|
1495
|
+
"(or the repo URL is wrong)."
|
|
1496
|
+
)
|
|
1497
|
+
if install_url:
|
|
1498
|
+
console.print(f"Install the app here: [cyan]{install_url}[/cyan]")
|
|
1499
|
+
console.print(
|
|
1500
|
+
f"Make sure [bold]{github_repo_url}[/bold] is selected during installation, "
|
|
1501
|
+
"then re-run [bold]cf push --remote[/bold]."
|
|
1502
|
+
)
|
|
1503
|
+
else:
|
|
1504
|
+
console.print("Install it from the project page in the portal, then retry.")
|
|
1505
|
+
raise click.Abort()
|
|
1506
|
+
|
|
1507
|
+
final_project_name = project_name or Path(project_root).name
|
|
1508
|
+
|
|
1509
|
+
if dry_run:
|
|
1510
|
+
console.print("\n[bold]Remote push preview:[/bold]")
|
|
1511
|
+
console.print(f" Platform project: {project.get('name')} ({platform_id})")
|
|
1512
|
+
console.print(f" GitHub repo: {github_repo_url}")
|
|
1513
|
+
console.print(f" Commit: {head_sha}")
|
|
1514
|
+
console.print(f" Via remote ref: {remote_ref}")
|
|
1515
|
+
console.print(f" EFS target: incoming/projects/{final_project_name}/")
|
|
1516
|
+
console.print(" (no files uploaded — dry run)")
|
|
1517
|
+
return
|
|
1518
|
+
|
|
1519
|
+
console.print(f"Asking platform to fetch [cyan]{head_sha[:7]}[/cyan] from {github_repo_url}…")
|
|
1520
|
+
console.print("[dim](large files may take several minutes — please keep this terminal open)[/dim]")
|
|
1521
|
+
try:
|
|
1522
|
+
resp = _api_post(
|
|
1523
|
+
f"/projects/{platform_id}/remote-push",
|
|
1524
|
+
{"commit_sha": head_sha, "project_name": final_project_name},
|
|
1525
|
+
timeout=600.0,
|
|
1526
|
+
)
|
|
1527
|
+
except SystemExit:
|
|
1528
|
+
raise click.Abort()
|
|
1529
|
+
|
|
1530
|
+
landed = resp.get("landed") or []
|
|
1531
|
+
if landed:
|
|
1532
|
+
console.print("[green]✓ Files staged on the platform:[/green]")
|
|
1533
|
+
for rel in landed:
|
|
1534
|
+
console.print(f" • {rel}")
|
|
1535
|
+
else:
|
|
1536
|
+
console.print("[yellow]⚠ Platform accepted the request but did not report any landed files.[/yellow]")
|
|
1537
|
+
|
|
1538
|
+
try:
|
|
1539
|
+
with open(Path(project_root) / ".cf" / "project.json", "r") as f:
|
|
1540
|
+
pj = json.load(f)
|
|
1541
|
+
_api_put(
|
|
1542
|
+
f"/projects/{platform_id}",
|
|
1543
|
+
{"cli_project_json": _slim_project_json(pj), "cli_sync_source": "push"},
|
|
1544
|
+
timeout=60.0,
|
|
1545
|
+
)
|
|
1546
|
+
console.print("[green]✓ Platform project synced[/green]")
|
|
1547
|
+
except SystemExit:
|
|
1548
|
+
console.print("[yellow]⚠ Remote push succeeded but platform sync failed[/yellow]")
|
|
1549
|
+
except Exception:
|
|
1550
|
+
console.print("[yellow]⚠ Could not read project.json for platform sync[/yellow]")
|
|
1551
|
+
|
|
1552
|
+
if submit:
|
|
1553
|
+
try:
|
|
1554
|
+
_api_post(f"/projects/{platform_id}/submit", {})
|
|
1555
|
+
console.print("[green]✓ Project submitted for review[/green]")
|
|
1556
|
+
except SystemExit:
|
|
1557
|
+
console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]")
|
|
1558
|
+
|
|
1559
|
+
|
|
1288
1560
|
@main.command('push')
|
|
1289
1561
|
@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).')
|
|
1290
1562
|
@click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
|
|
@@ -1296,8 +1568,17 @@ def gpio_config(project_root, view):
|
|
|
1296
1568
|
@click.option('--force-overwrite', is_flag=True, help='Overwrite existing files on SFTP without prompting.')
|
|
1297
1569
|
@click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.')
|
|
1298
1570
|
@click.option('--submit', is_flag=True, help='Submit the project for review after upload.')
|
|
1299
|
-
|
|
1300
|
-
|
|
1571
|
+
@click.option('--remote', is_flag=True, help='Use the ChipFoundry GitHub App (HTTPS only) instead of SFTP. Useful when port 22 is blocked by a corporate firewall.')
|
|
1572
|
+
def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote):
|
|
1573
|
+
"""Upload your project files to the ChipFoundry SFTP server (or via GitHub with --remote)."""
|
|
1574
|
+
if remote:
|
|
1575
|
+
_push_remote(
|
|
1576
|
+
project_root=project_root,
|
|
1577
|
+
project_name=project_name,
|
|
1578
|
+
dry_run=dry_run,
|
|
1579
|
+
submit=submit,
|
|
1580
|
+
)
|
|
1581
|
+
return
|
|
1301
1582
|
# If .cf/project.json exists in cwd, use it as default project_root and project_name
|
|
1302
1583
|
cwd_root, cwd_project_name = get_project_json_from_cwd()
|
|
1303
1584
|
if not project_root and cwd_root:
|
|
@@ -3943,11 +4224,19 @@ def _api_get(path: str):
|
|
|
3943
4224
|
client.close()
|
|
3944
4225
|
|
|
3945
4226
|
|
|
3946
|
-
def _api_post(path: str, json_data: dict):
|
|
3947
|
-
"""Authenticated POST to the platform API. Returns parsed JSON or raises SystemExit.
|
|
4227
|
+
def _api_post(path: str, json_data: dict, timeout: Optional[float] = None):
|
|
4228
|
+
"""Authenticated POST to the platform API. Returns parsed JSON or raises SystemExit.
|
|
4229
|
+
|
|
4230
|
+
`timeout` (seconds) overrides the client default for this request only.
|
|
4231
|
+
Use a large value for long-running endpoints such as remote-push, which
|
|
4232
|
+
waits for the platform to fetch files from GitHub and stage them on EFS.
|
|
4233
|
+
"""
|
|
3948
4234
|
client, _ = _api_client()
|
|
3949
4235
|
try:
|
|
3950
|
-
|
|
4236
|
+
kwargs = {"json": json_data}
|
|
4237
|
+
if timeout is not None:
|
|
4238
|
+
kwargs["timeout"] = timeout
|
|
4239
|
+
resp = client.post(path, **kwargs)
|
|
3951
4240
|
if resp.status_code == 401:
|
|
3952
4241
|
console.print("[red]✗ API key is invalid or expired.[/red] Run [bold]cf login[/bold] to re-authenticate.")
|
|
3953
4242
|
raise SystemExit(1)
|
|
@@ -3962,11 +4251,17 @@ def _api_post(path: str, json_data: dict):
|
|
|
3962
4251
|
client.close()
|
|
3963
4252
|
|
|
3964
4253
|
|
|
3965
|
-
def _api_put(path: str, json_data: dict):
|
|
3966
|
-
"""Authenticated PUT to the platform API. Returns parsed JSON or raises SystemExit.
|
|
4254
|
+
def _api_put(path: str, json_data: dict, timeout: Optional[float] = None):
|
|
4255
|
+
"""Authenticated PUT to the platform API. Returns parsed JSON or raises SystemExit.
|
|
4256
|
+
|
|
4257
|
+
`timeout` (seconds) overrides the client default for this request only.
|
|
4258
|
+
"""
|
|
3967
4259
|
client, _ = _api_client()
|
|
3968
4260
|
try:
|
|
3969
|
-
|
|
4261
|
+
kwargs = {"json": json_data}
|
|
4262
|
+
if timeout is not None:
|
|
4263
|
+
kwargs["timeout"] = timeout
|
|
4264
|
+
resp = client.put(path, **kwargs)
|
|
3970
4265
|
if resp.status_code == 401:
|
|
3971
4266
|
console.print("[red]✗ API key is invalid or expired.[/red] Run [bold]cf login[/bold] to re-authenticate.")
|
|
3972
4267
|
raise SystemExit(1)
|
|
@@ -226,3 +226,109 @@ def verify_remote_precheck_repo(
|
|
|
226
226
|
raise RemotePrecheckGitError(
|
|
227
227
|
f"{rel!r} has uncommitted changes. Commit or stash before remote precheck."
|
|
228
228
|
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class RemotePushGitError(Exception):
|
|
232
|
+
"""Local repository state is not consistent with origin for remote push."""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _head_on_any_remote_ref(repo: Path, head_sha: str) -> Optional[str]:
|
|
236
|
+
"""Return a remote ref name that contains HEAD's commit, or None.
|
|
237
|
+
|
|
238
|
+
Uses `git branch -r --contains` so the check passes for any remote branch
|
|
239
|
+
that has been pushed, without pinning to a single named branch.
|
|
240
|
+
"""
|
|
241
|
+
r = _run_git(repo, "branch", "-r", "--contains", head_sha)
|
|
242
|
+
if r.returncode != 0:
|
|
243
|
+
return None
|
|
244
|
+
for line in r.stdout.splitlines():
|
|
245
|
+
name = line.strip().lstrip("*").strip()
|
|
246
|
+
if not name or " -> " in name:
|
|
247
|
+
continue
|
|
248
|
+
return name
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _push_critical_paths(repo: Path, project_json: Path) -> Set[str]:
|
|
253
|
+
"""Paths that must be clean at HEAD for a remote push to match local state."""
|
|
254
|
+
try:
|
|
255
|
+
kind_gds, gds_rel = _detect_wrapper_gds(repo)
|
|
256
|
+
except RemotePrecheckGitError as e:
|
|
257
|
+
# Re-raise under the push error class so the CLI surfaces one consistent
|
|
258
|
+
# message and can catch a single exception type.
|
|
259
|
+
raise RemotePushGitError(
|
|
260
|
+
f"{e} "
|
|
261
|
+
"Check that the wrapper GDS is committed and located under the "
|
|
262
|
+
"expected path (e.g. gds/user_project_wrapper.gds, "
|
|
263
|
+
"gds/openframe_project_wrapper.gds, etc.). If you use Git LFS, "
|
|
264
|
+
"run `git lfs pull` so the actual file (not the pointer) is present."
|
|
265
|
+
) from e
|
|
266
|
+
out: Set[str] = {gds_rel}
|
|
267
|
+
|
|
268
|
+
cf_type = _load_cf_project_type(project_json)
|
|
269
|
+
if cf_type and cf_type != kind_gds:
|
|
270
|
+
raise RemotePushGitError(
|
|
271
|
+
f".cf/project.json type is {cf_type!r} but the wrapper GDS indicates {kind_gds!r}. "
|
|
272
|
+
"Fix project type or GDS layout before remote push."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# user_defines.v is required except for openframe, matching collect_project_files().
|
|
276
|
+
if kind_gds != "openframe":
|
|
277
|
+
ud = repo / USER_DEFINES_REL
|
|
278
|
+
if ud.is_file() or _path_tracked_in_git(repo, USER_DEFINES_REL):
|
|
279
|
+
out.add(USER_DEFINES_REL)
|
|
280
|
+
|
|
281
|
+
if _path_tracked_in_git(repo, CF_PROJECT_JSON_REL):
|
|
282
|
+
out.add(CF_PROJECT_JSON_REL)
|
|
283
|
+
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def verify_push_repo(project_root: Path) -> Tuple[str, str]:
|
|
288
|
+
"""
|
|
289
|
+
Ensure the local checkout is safe for a remote push: HEAD is reachable from
|
|
290
|
+
a remote branch and the files the platform will fetch are clean at HEAD.
|
|
291
|
+
|
|
292
|
+
Returns (head_sha, remote_ref_containing_head).
|
|
293
|
+
"""
|
|
294
|
+
repo = project_root.resolve()
|
|
295
|
+
git_marker = repo / ".git"
|
|
296
|
+
if not (git_marker.is_dir() or git_marker.is_file()):
|
|
297
|
+
raise RemotePushGitError(
|
|
298
|
+
"Remote push requires a git checkout with .git "
|
|
299
|
+
"(clone your GitHub repo rather than using a plain folder copy)."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
head_sha = _local_head_sha(repo)
|
|
303
|
+
remote_ref = _head_on_any_remote_ref(repo, head_sha)
|
|
304
|
+
if not remote_ref:
|
|
305
|
+
raise RemotePushGitError(
|
|
306
|
+
f"HEAD ({head_sha[:7]}) is not on any remote ref. "
|
|
307
|
+
"Push your commits to GitHub (e.g. `git push`) before running `cf push --remote`."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
project_json = repo / ".cf" / "project.json"
|
|
311
|
+
critical = _push_critical_paths(repo, project_json)
|
|
312
|
+
|
|
313
|
+
dirty = _porcelain_paths(repo)
|
|
314
|
+
for entry in dirty:
|
|
315
|
+
if entry.startswith("??"):
|
|
316
|
+
path = entry[2:]
|
|
317
|
+
if path in critical:
|
|
318
|
+
raise RemotePushGitError(
|
|
319
|
+
f"{path!r} is untracked but required for remote push. "
|
|
320
|
+
"Add and commit it (or remove it) so the remote fetch matches your machine."
|
|
321
|
+
)
|
|
322
|
+
elif entry in critical:
|
|
323
|
+
raise RemotePushGitError(
|
|
324
|
+
f"{entry!r} has uncommitted changes. Commit and push before remote push."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
for rel in sorted(critical):
|
|
328
|
+
r = _run_git(repo, "diff-index", "--quiet", "HEAD", "--", rel)
|
|
329
|
+
if r.returncode != 0:
|
|
330
|
+
raise RemotePushGitError(
|
|
331
|
+
f"{rel!r} has uncommitted changes. Commit and push before remote push."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return head_sha, remote_ref
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import shutil
|
|
3
|
+
import subprocess
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from typing import Dict, Optional, Any
|
|
5
|
+
from typing import Dict, List, Optional, Tuple, Any
|
|
5
6
|
import json
|
|
6
7
|
import hashlib
|
|
7
8
|
import paramiko
|
|
@@ -25,6 +26,64 @@ GDS_TYPE_MAP = {
|
|
|
25
26
|
'openframe_project_wrapper.gds.gz': 'openframe',
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
# Canonical GDS wrapper layouts used by remote precheck and remote push.
|
|
30
|
+
# Each entry is (project_kind, base path without suffix). Suffixes below.
|
|
31
|
+
# Keep in sync with chipignite-backend-services/src/precheck_service and
|
|
32
|
+
# sftp-admin/lambda/CreateSftpHomeDirectory.py stage_push_files action.
|
|
33
|
+
GDS_WRAPPER_BASES: Tuple[Tuple[str, str], ...] = (
|
|
34
|
+
("analog", "gds/user_analog_project_wrapper"),
|
|
35
|
+
("digital", "gds/user_project_wrapper"),
|
|
36
|
+
("openframe", "gds/openframe_project_wrapper"),
|
|
37
|
+
)
|
|
38
|
+
GDS_WRAPPER_SUFFIXES: Tuple[str, ...] = (".gds", ".gds.gz")
|
|
39
|
+
|
|
40
|
+
USER_DEFINES_REL = "verilog/rtl/user_defines.v"
|
|
41
|
+
CF_PROJECT_JSON_REL = ".cf/project.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_github_repo_url(project_root: str) -> Optional[str]:
|
|
45
|
+
"""
|
|
46
|
+
Return a normalized https://github.com/owner/repo URL for `origin`, or None.
|
|
47
|
+
|
|
48
|
+
Handles HTTPS remotes (with or without .git suffix) and SSH remotes
|
|
49
|
+
(git@github.com:owner/repo.git). Non-GitHub remotes return None silently
|
|
50
|
+
so callers can just pre-fill the prompt when a GitHub remote is present.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
r = subprocess.run(
|
|
54
|
+
["git", "-C", str(project_root), "remote", "get-url", "origin"],
|
|
55
|
+
capture_output=True, text=True, timeout=10,
|
|
56
|
+
)
|
|
57
|
+
except (OSError, subprocess.SubprocessError):
|
|
58
|
+
return None
|
|
59
|
+
if r.returncode != 0:
|
|
60
|
+
return None
|
|
61
|
+
raw = (r.stdout or "").strip()
|
|
62
|
+
if not raw:
|
|
63
|
+
return None
|
|
64
|
+
m = re.match(r"^git@github\.com:([^/]+)/(.+?)(?:\.git)?$", raw)
|
|
65
|
+
if m:
|
|
66
|
+
return f"https://github.com/{m.group(1)}/{m.group(2)}"
|
|
67
|
+
if raw.startswith(("https://github.com/", "http://github.com/")):
|
|
68
|
+
cleaned = raw.removesuffix(".git")
|
|
69
|
+
return cleaned.replace("http://", "https://", 1)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_head_commit_sha(project_root: str) -> Optional[str]:
|
|
74
|
+
"""Return the full commit SHA at HEAD, or None if not a git checkout."""
|
|
75
|
+
try:
|
|
76
|
+
r = subprocess.run(
|
|
77
|
+
["git", "-C", str(project_root), "rev-parse", "HEAD"],
|
|
78
|
+
capture_output=True, text=True, timeout=10,
|
|
79
|
+
)
|
|
80
|
+
except (OSError, subprocess.SubprocessError):
|
|
81
|
+
return None
|
|
82
|
+
if r.returncode != 0:
|
|
83
|
+
return None
|
|
84
|
+
sha = (r.stdout or "").strip()
|
|
85
|
+
return sha if re.fullmatch(r"[0-9a-f]{40}", sha) else None
|
|
86
|
+
|
|
28
87
|
def collect_project_files(project_root: str) -> Dict[str, Optional[str]]:
|
|
29
88
|
"""
|
|
30
89
|
Collect required project files from the given project_root.
|
|
File without changes
|
|
File without changes
|