chipfoundry-cli 2.3.14__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 2.3.14
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 New Project
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
- > This command **must be run first** after cloning a repository. It is required before running:
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
- - **Smart defaults**: Auto-detects project name from directory and project type from GDS files
277
- - **Interactive prompts**: Shows detected values in prompts for easy acceptance
278
- - **Shuttle selection**: Prompts to select an available shuttle (sorted by nearest deadline)
279
- - **Platform registration**: Creates the project on the platform and links it automatically
280
- - Creates `.cf/project.json` with project metadata
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
- **What happens:**
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 New Project
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
- > This command **must be run first** after cloning a repository. It is required before running:
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
- - **Smart defaults**: Auto-detects project name from directory and project type from GDS files
251
- - **Interactive prompts**: Shows detected values in prompts for easy acceptance
252
- - **Shuttle selection**: Prompts to select an available shuttle (sorted by nearest deadline)
253
- - **Platform registration**: Creates the project on the platform and links it automatically
254
- - Creates `.cf/project.json` with project metadata
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
- **What happens:**
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
@@ -306,31 +308,87 @@ def keyview():
306
308
  print("")
307
309
  _print_manual_key_instructions()
308
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
+
362
+
309
363
  @main.command('init')
310
- @click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Directory to create the project in (defaults to current directory).')
364
+ @click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Project directory (defaults to current directory).')
311
365
  @click.option('--shuttle', default=None, help='Shuttle name or ID to associate with the project.')
312
- @click.option('--description', default=None, help='Project description.')
366
+ @click.option('--description', default=None, help='Project description (skips description prompt).')
313
367
  def init(project_root, shuttle, description):
314
- """Initialize a new ChipFoundry project (.cf/project.json) in the given directory."""
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
+ """
315
376
  if not project_root:
316
377
  project_root = os.getcwd()
378
+ project_root = str(Path(project_root).resolve())
317
379
  cf_dir = Path(project_root) / '.cf'
318
380
  cf_dir.mkdir(parents=True, exist_ok=True)
319
381
  project_json_path = cf_dir / 'project.json'
320
382
 
321
- existing_platform_id = None
383
+ local_data: dict = {}
322
384
  if project_json_path.exists():
323
- with open(project_json_path) as f:
324
- existing_data = json.load(f)
325
- existing_platform_id = existing_data.get('project', {}).get('platform_project_id')
326
- if existing_platform_id:
327
- console.print(f"[yellow]This project is already linked to platform project {existing_platform_id}.[/yellow]")
328
- console.print("Use [bold]cf status[/bold] to check it or [bold]cf unlink[/bold] to disconnect.")
329
- return
330
- overwrite = console.input(f"[yellow]project.json already exists at {project_json_path}. Overwrite? (y/N): [/yellow]").strip().lower()
331
- if overwrite != 'y':
332
- console.print("[red]Aborted project initialization.[/red]")
333
- 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 {}
334
392
 
335
393
  config = load_user_config()
336
394
  username = config.get("sftp_username")
@@ -347,92 +405,159 @@ def init(project_root, shuttle, description):
347
405
  console.print("[bold red]No SFTP account linked to your platform account. Please run 'cf login' first.[/bold red]")
348
406
  raise click.Abort()
349
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
350
432
  gds_dir = Path(project_root) / 'gds'
351
- gds_type = None
352
433
  for gds_name, gtype in GDS_TYPE_MAP.items():
353
434
  if (gds_dir / gds_name).exists():
354
- gds_type = gtype
435
+ detected_type = gtype
355
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()
449
+
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)
356
454
 
357
- default_name = Path(project_root).name
358
- name = console.input(f"Project name (detected: [cyan]{default_name}[/cyan]): ").strip() or default_name
455
+ github_repo_url = _prompt_with_default("GitHub repo URL", current_github, detected_github)
359
456
 
360
- if gds_type:
361
- project_type = console.input(f"Project type (digital/analog/openframe) (detected: [cyan]{gds_type}[/cyan]): ").strip() or gds_type
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
362
467
  else:
363
- project_type = console.input("Project type (digital/analog/openframe): ").strip()
364
-
365
- version = "1"
366
- data = {
367
- "project": {
368
- "name": name,
369
- "type": project_type,
370
- "user": username,
371
- "version": version,
372
- "user_project_wrapper_hash": "",
373
- "submission_state": "Draft"
374
- }
375
- }
468
+ proj.pop('github_repo_url', None)
376
469
 
377
- api_key = config.get('api_key')
378
- if api_key:
379
- shuttle_id = None
380
- if not shuttle:
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:
381
489
  try:
382
- shuttles = _api_get("/shuttles/available")
383
- if shuttles:
384
- shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31'))
385
- console.print("\n[bold]Available shuttles:[/bold]")
386
- for i, s in enumerate(shuttles, 1):
387
- deadline = s.get('tapeout_date', '')
388
- console.print(f" [cyan]{i}[/cyan]. {s['name']}{f' — submission deadline {deadline}' if deadline else ''}")
389
- console.print(f" [cyan]{len(shuttles) + 1}[/cyan]. Skip — choose later")
390
- choice = console.input("\nSelect shuttle: ").strip()
391
- try:
392
- idx = int(choice) - 1
393
- if 0 <= idx < len(shuttles):
394
- shuttle_id = shuttles[idx]['id']
395
- except (ValueError, IndexError):
396
- 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())})")
397
493
  except SystemExit:
398
- console.print("[dim]Could not fetch shuttles continuing without shuttle selection.[/dim]")
494
+ console.print("[yellow]Platform update failedlocal changes saved.[/yellow]")
399
495
  else:
400
- shuttle_id = shuttle
496
+ console.print("[dim]No platform changes needed.[/dim]")
401
497
 
402
- create_data = {
403
- "name": name,
404
- "description": description or "",
405
- "design_type": project_type,
406
- "registration_source": "cli",
407
- }
408
- if shuttle_id:
409
- create_data["shuttle_id"] = str(shuttle_id)
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
410
508
 
509
+ shuttle_id = shuttle
510
+ if not shuttle_id:
411
511
  try:
412
- project_resp = _api_post("/projects", create_data)
413
- platform_id = project_resp.get('id')
414
- data['project']['platform_project_id'] = platform_id
415
-
416
- with open(project_json_path, 'w') as f:
417
- json.dump(data, f, indent=2)
418
-
419
- portal_url = _get_portal_url()
420
- console.print(f"\n[green]✓ Project created on platform[/green]")
421
- console.print(f" Name: {name}")
422
- console.print(f" ID: {platform_id}")
423
- if project_resp.get('shuttle_name'):
424
- console.print(f" Shuttle: {project_resp['shuttle_name']}")
425
- console.print(f" Status: Draft")
426
- console.print(f" Portal: {portal_url}/projects/{platform_id}")
427
- 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
428
527
  except SystemExit:
429
- console.print("[yellow]Platform project creation failedsaving local project only.[/yellow]")
430
- else:
431
- console.print("[dim]Tip: Run [bold]cf login[/bold] to connect this project to the platform.[/dim]")
528
+ console.print("[dim]Could not fetch shuttlescontinuing without shuttle selection.[/dim]")
432
529
 
433
- with open(project_json_path, 'w') as f:
434
- json.dump(data, f, indent=2)
435
- console.print(f"[green]✓ Initialized project at {project_json_path}[/green]")
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)
436
561
 
437
562
  @main.command('gpio-config')
438
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).')
@@ -1296,6 +1421,142 @@ def gpio_config(project_root, view):
1296
1421
  console.print(f"[red]Error updating user_defines.v: {e}[/red]")
1297
1422
 
1298
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
+
1299
1560
  @main.command('push')
1300
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).')
1301
1562
  @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
@@ -1307,8 +1568,17 @@ def gpio_config(project_root, view):
1307
1568
  @click.option('--force-overwrite', is_flag=True, help='Overwrite existing files on SFTP without prompting.')
1308
1569
  @click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.')
1309
1570
  @click.option('--submit', is_flag=True, help='Submit the project for review after upload.')
1310
- def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit):
1311
- """Upload your project files to the ChipFoundry SFTP server."""
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
1312
1582
  # If .cf/project.json exists in cwd, use it as default project_root and project_name
1313
1583
  cwd_root, cwd_project_name = get_project_json_from_cwd()
1314
1584
  if not project_root and cwd_root:
@@ -3954,11 +4224,19 @@ def _api_get(path: str):
3954
4224
  client.close()
3955
4225
 
3956
4226
 
3957
- def _api_post(path: str, json_data: dict):
3958
- """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
+ """
3959
4234
  client, _ = _api_client()
3960
4235
  try:
3961
- resp = client.post(path, json=json_data)
4236
+ kwargs = {"json": json_data}
4237
+ if timeout is not None:
4238
+ kwargs["timeout"] = timeout
4239
+ resp = client.post(path, **kwargs)
3962
4240
  if resp.status_code == 401:
3963
4241
  console.print("[red]✗ API key is invalid or expired.[/red] Run [bold]cf login[/bold] to re-authenticate.")
3964
4242
  raise SystemExit(1)
@@ -3973,11 +4251,17 @@ def _api_post(path: str, json_data: dict):
3973
4251
  client.close()
3974
4252
 
3975
4253
 
3976
- def _api_put(path: str, json_data: dict):
3977
- """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
+ """
3978
4259
  client, _ = _api_client()
3979
4260
  try:
3980
- resp = client.put(path, json=json_data)
4261
+ kwargs = {"json": json_data}
4262
+ if timeout is not None:
4263
+ kwargs["timeout"] = timeout
4264
+ resp = client.put(path, **kwargs)
3981
4265
  if resp.status_code == 401:
3982
4266
  console.print("[red]✗ API key is invalid or expired.[/red] Run [bold]cf login[/bold] to re-authenticate.")
3983
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.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.3.14"
3
+ version = "2.3.19"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"