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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 2.3.13
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
@@ -176,21 +178,40 @@ def main():
176
178
 
177
179
  @main.command('config')
178
180
  def config_cmd():
179
- """Configure user-level SFTP credentials (username and key)."""
180
- console.print("[bold cyan]ChipFoundry CLI User Configuration[/bold cyan]")
181
- username = console.input("Enter your ChipFoundry SFTP username: ").strip()
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
- "sftp_username": username,
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
- console.print("[bold cyan]Next steps:[/bold cyan]")
215
- console.print("1. Copy the public key above")
216
- console.print("2. Submit it to the registration form at: https://chipfoundry.io/sftp-registration")
217
- console.print("3. Wait for account approval")
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
- # Display instructions
260
- console.print("[bold cyan]Next steps:[/bold cyan]")
261
- console.print("1. Copy the public key above")
262
- console.print("2. Submit it to the registration form at: https://chipfoundry.io/sftp-registration")
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
- console.print("[bold cyan]Next steps:[/bold cyan]")
293
- console.print("1. Copy the public key above")
294
- console.print("2. Submit it to the registration form at: https://chipfoundry.io/sftp-registration")
295
- console.print("3. Wait for account approval")
296
- console.print("4. Use 'cf config' to configure your SFTP credentials")
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='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).')
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 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
+ """
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
- existing_platform_id = None
383
+ local_data: dict = {}
311
384
  if project_json_path.exists():
312
- with open(project_json_path) as f:
313
- existing_data = json.load(f)
314
- existing_platform_id = existing_data.get('project', {}).get('platform_project_id')
315
- if existing_platform_id:
316
- console.print(f"[yellow]This project is already linked to platform project {existing_platform_id}.[/yellow]")
317
- console.print("Use [bold]cf status[/bold] to check it or [bold]cf unlink[/bold] to disconnect.")
318
- return
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
- gds_type = gtype
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
- default_name = Path(project_root).name
347
- name = console.input(f"Project name (detected: [cyan]{default_name}[/cyan]): ").strip() or default_name
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 gds_type:
350
- 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
351
467
  else:
352
- project_type = console.input("Project type (digital/analog/openframe): ").strip()
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
- api_key = config.get('api_key')
367
- if api_key:
368
- shuttle_id = None
369
- 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:
370
489
  try:
371
- shuttles = _api_get("/shuttles/available")
372
- if shuttles:
373
- shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31'))
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("[dim]Could not fetch shuttles continuing without shuttle selection.[/dim]")
494
+ console.print("[yellow]Platform update failedlocal changes saved.[/yellow]")
388
495
  else:
389
- shuttle_id = shuttle
496
+ console.print("[dim]No platform changes needed.[/dim]")
390
497
 
391
- create_data = {
392
- "name": name,
393
- "description": description or "",
394
- "design_type": project_type,
395
- "registration_source": "cli",
396
- }
397
- if shuttle_id:
398
- 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
399
508
 
509
+ shuttle_id = shuttle
510
+ if not shuttle_id:
400
511
  try:
401
- project_resp = _api_post("/projects", create_data)
402
- platform_id = project_resp.get('id')
403
- data['project']['platform_project_id'] = platform_id
404
-
405
- with open(project_json_path, 'w') as f:
406
- json.dump(data, f, indent=2)
407
-
408
- portal_url = _get_portal_url()
409
- console.print(f"\n[green]✓ Project created on platform[/green]")
410
- console.print(f" Name: {name}")
411
- console.print(f" ID: {platform_id}")
412
- if project_resp.get('shuttle_name'):
413
- console.print(f" Shuttle: {project_resp['shuttle_name']}")
414
- console.print(f" Status: Draft")
415
- console.print(f" Portal: {portal_url}/projects/{platform_id}")
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("[yellow]Platform project creation failedsaving local project only.[/yellow]")
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 shuttlescontinuing without shuttle selection.[/dim]")
421
529
 
422
- with open(project_json_path, 'w') as f:
423
- json.dump(data, f, indent=2)
424
- 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)
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
- def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit):
1300
- """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
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
- 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)
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
- 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)
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.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.3.13"
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"