mainsequence 6.0.37__tar.gz → 6.0.38__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.
Files changed (128) hide show
  1. {mainsequence-6.0.37 → mainsequence-6.0.38}/PKG-INFO +1 -1
  2. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/maintenance/project-maintenance/SKILL.md +20 -7
  3. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/cli.py +163 -42
  4. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/local_ops.py +39 -0
  5. mainsequence-6.0.38/mainsequence/cli/ssh_utils.py +365 -0
  6. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/models_foundry.py +26 -3
  7. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence.egg-info/PKG-INFO +1 -1
  8. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence.egg-info/SOURCES.txt +2 -0
  9. {mainsequence-6.0.37 → mainsequence-6.0.38}/pyproject.toml +1 -1
  10. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_cli.py +302 -11
  11. mainsequence-6.0.38/tests/test_local_ops.py +59 -0
  12. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_pod_project_resolution.py +3 -3
  13. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_project_branch_contract.py +33 -3
  14. mainsequence-6.0.38/tests/test_ssh_utils.py +187 -0
  15. mainsequence-6.0.37/mainsequence/cli/ssh_utils.py +0 -184
  16. {mainsequence-6.0.37 → mainsequence-6.0.38}/LICENSE +0 -0
  17. {mainsequence-6.0.37 → mainsequence-6.0.38}/README.md +0 -0
  18. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/AGENTS.md +0 -0
  19. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  20. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  21. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/data_access/exploration/SKILL.md +0 -0
  22. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/data_publishing/data_nodes/SKILL.md +0 -0
  23. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/data_publishing/meta_table_migrations/SKILL.md +0 -0
  24. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/data_publishing/meta_tables/SKILL.md +0 -0
  25. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  26. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  27. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  28. {mainsequence-6.0.37 → mainsequence-6.0.38}/agent_scaffold/skills/sdk_project_execution/SKILL.md +0 -0
  29. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/__init__.py +0 -0
  30. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/__main__.py +0 -0
  31. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/bootstrap.py +0 -0
  32. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/__init__.py +0 -0
  33. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/api.py +0 -0
  34. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/browser_auth.py +0 -0
  35. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/config.py +0 -0
  36. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/docker_utils.py +0 -0
  37. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/doctor.py +0 -0
  38. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/migrations.py +0 -0
  39. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/model_filters.py +0 -0
  40. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/project_status.py +0 -0
  41. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/pydantic_cli.py +0 -0
  42. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/sdk_utils.py +0 -0
  43. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/cli/ui.py +0 -0
  44. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/__init__.py +0 -0
  45. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/agent_runtime_models.py +0 -0
  46. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/base.py +0 -0
  47. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/client.py +0 -0
  48. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/compute_validation.py +0 -0
  49. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  50. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/data_sources_interfaces/duckdb.py +0 -0
  51. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/data_sources_interfaces/local_paths.py +0 -0
  52. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/data_sources_interfaces/sqlite.py +0 -0
  53. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/dtype_codec.py +0 -0
  54. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/exceptions.py +0 -0
  55. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/metatables/__init__.py +0 -0
  56. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/metatables/core.py +0 -0
  57. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/models_helpers.py +0 -0
  58. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/models_user.py +0 -0
  59. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/client/utils.py +0 -0
  60. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/defaults.py +0 -0
  61. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/instrumentation/__init__.py +0 -0
  62. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/instrumentation/utils.py +0 -0
  63. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/logconf.py +0 -0
  64. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/__init__.py +0 -0
  65. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/__main__.py +0 -0
  66. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/compiled_sql/__init__.py +0 -0
  67. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/compiled_sql/v1.py +0 -0
  68. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/__init__.py +0 -0
  69. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/build_operations.py +0 -0
  70. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/data_nodes.py +0 -0
  71. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/models.py +0 -0
  72. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/namespacing.py +0 -0
  73. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/persist_managers.py +0 -0
  74. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/run_operations.py +0 -0
  75. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/data_nodes/utils.py +0 -0
  76. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/future_registry.py +0 -0
  77. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/hashing.py +0 -0
  78. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/__init__.py +0 -0
  79. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/alembic.py +0 -0
  80. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/env.py +0 -0
  81. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/provider.py +0 -0
  82. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/registry.py +0 -0
  83. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/scaffold.py +0 -0
  84. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/templates/__init__.py +0 -0
  85. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/templates/env.py.mako +0 -0
  86. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/migrations/templates/script.py.mako +0 -0
  87. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/pydantic_metadata.py +0 -0
  88. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/schema_names.py +0 -0
  89. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/meta_tables/sqlalchemy_contracts.py +0 -0
  90. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/project_skills.py +0 -0
  91. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/runtime_context.py +0 -0
  92. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/runtime_flags.py +0 -0
  93. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence/scaffold_skills.py +0 -0
  94. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence.egg-info/dependency_links.txt +0 -0
  95. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence.egg-info/entry_points.txt +0 -0
  96. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence.egg-info/requires.txt +0 -0
  97. {mainsequence-6.0.37 → mainsequence-6.0.38}/mainsequence.egg-info/top_level.txt +0 -0
  98. {mainsequence-6.0.37 → mainsequence-6.0.38}/setup.cfg +0 -0
  99. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_auth_precedence.py +0 -0
  100. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_build_operations_hashing.py +0 -0
  101. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_canonical_api_route_contract.py +0 -0
  102. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_cli_browser_auth.py +0 -0
  103. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_cli_migrations.py +0 -0
  104. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_client.py +0 -0
  105. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_data_access_mixin_dimension_audit.py +0 -0
  106. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_data_node_storage_dimension_queries.py +0 -0
  107. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_data_node_update_flow.py +0 -0
  108. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_dependency_extras.py +0 -0
  109. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_duckdb_interface_dimensions.py +0 -0
  110. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_filter_normalization.py +0 -0
  111. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_instrumentation.py +0 -0
  112. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_logconf.py +0 -0
  113. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_meta_table_migrations.py +0 -0
  114. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_meta_tables_client_models.py +0 -0
  115. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_meta_tables_sqlalchemy_contracts.py +0 -0
  116. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_models_user_request_bound_auth.py +0 -0
  117. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_notification_uid_contract.py +0 -0
  118. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_project_batch_jobs_from_file.py +0 -0
  119. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_project_skills.py +0 -0
  120. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_run_configuration.py +0 -0
  121. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_scaffold_skills.py +0 -0
  122. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_schema_names.py +0 -0
  123. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_secret_client_model.py +0 -0
  124. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_source_table_configuration.py +0 -0
  125. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_sqlite_interface_dimensions.py +0 -0
  126. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_update_runner_uid_runtime.py +0 -0
  127. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_update_statistics.py +0 -0
  128. {mainsequence-6.0.37 → mainsequence-6.0.38}/tests/test_update_uid_guards.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 6.0.37
3
+ Version: 6.0.38
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: LicenseRef-MainSequence-Proprietary
@@ -192,6 +192,12 @@ a detached checkout and rejects a Git branch that is not registered under the
192
192
  logical Project. Do not bypass that validation or supply a ProjectBranch UID
193
193
  manually. Register or select the correct Git branch first.
194
194
 
195
+ After that branch preflight, `--dry-run` resolves the existing `uv` executable,
196
+ previews its patch version without mutation, requests the backend-owned tag for
197
+ that future version, rejects an invalid or existing local tag, prints the
198
+ complete plan, and returns without generating an SSH key, querying private
199
+ remote refs, changing dependencies or project files, or mutating Git state.
200
+
195
201
  After review:
196
202
 
197
203
  ```bash
@@ -200,13 +206,20 @@ mainsequence project sync --path . -m "<specific commit message>"
200
206
 
201
207
  The canonical command always:
202
208
 
203
- 1. applies a patch version bump;
204
- 2. runs `uv lock`;
205
- 3. runs `uv sync`;
206
- 4. exports locked production requirements;
207
- 5. stages and commits the changes;
208
- 6. creates an annotated `v<version>` tag; and
209
- 7. pushes the branch and tag with `--follow-tags`.
209
+ 1. selects `mainsequence-<repository-slug>-<first-16-sha256>` from the normalized
210
+ `host[:non-default-port]/repository/path`, never from the repository basename alone and never
211
+ from a legacy basename-only key;
212
+ 2. previews the `uv` patch version, requests the backend-owned ProjectBranch tag, and rejects an
213
+ invalid or existing local tag;
214
+ 3. registers a new or inaccessible key through the owning Project and verifies a dry-run push with
215
+ that forced identity;
216
+ 4. queries the exact tag ref on `origin` and stops if it exists or cannot be checked;
217
+ 5. applies the patch version bump and verifies it matches the preview;
218
+ 6. runs `uv lock` and `uv sync`;
219
+ 7. exports locked production requirements;
220
+ 8. stages and commits the changes;
221
+ 9. creates the returned annotated tag unchanged; and
222
+ 10. atomically pushes the explicit branch and tag refs with `--follow-tags`.
210
223
 
211
224
  Do not offer alternate bump modes, a no-push mode, or a hand-written sequence
212
225
  of equivalent commands. Do not call `sync-after-commit` or any backend repair
@@ -214,6 +214,7 @@ from .local_ops import (
214
214
  run_cmd,
215
215
  run_uv,
216
216
  uv_export_requirements,
217
+ uv_preview_patch_version,
217
218
  uv_project_version,
218
219
  )
219
220
  from .migrations import migrations as migrations_group
@@ -228,9 +229,16 @@ from .pydantic_cli import (
228
229
  from .sdk_utils import fetch_latest_sdk_version, normalize_version, read_local_sdk_version
229
230
  from .ssh_utils import (
230
231
  ensure_key_for_repo,
232
+ git_ssh_environment,
231
233
  open_folder,
232
234
  open_signed_terminal,
235
+ repository_ssh_key_paths,
236
+ require_ssh_git_origin,
233
237
  start_agent_and_add_key,
238
+ verify_git_push_access,
239
+ verify_git_remote_access,
240
+ verify_git_remote_tag_absent,
241
+ verify_git_tag_absent,
234
242
  )
235
243
  from .ui import error, info, print_kv, print_table, status, success, warn
236
244
 
@@ -674,6 +682,48 @@ def _resolve_project_repository_ssh_url(project: dict) -> str:
674
682
  return repository_ssh_url
675
683
 
676
684
 
685
+ def _ensure_project_repository_ssh_access(
686
+ *,
687
+ origin: str,
688
+ project_ref: str | None,
689
+ verify_access,
690
+ ) -> tuple[pathlib.Path, str, dict[str, str]]:
691
+ expected_key_path, expected_public_key_path = repository_ssh_key_paths(origin)
692
+ keypair_existed = expected_key_path.is_file() and expected_public_key_path.is_file()
693
+ key_path, _public_key_path, public_key = ensure_key_for_repo(origin)
694
+ env = git_ssh_environment(key_path)
695
+
696
+ def register_key() -> None:
697
+ normalized_project_ref = str(project_ref or "").strip()
698
+ if not normalized_project_ref:
699
+ raise ApiError(
700
+ "The repository SSH key is not authorized and no MAIN_SEQUENCE_PROJECT_UID "
701
+ "is available for deploy-key registration."
702
+ )
703
+ key_title = str(platform.node() or "").strip()
704
+ if not key_title or "\n" in key_title or "\r" in key_title:
705
+ raise ApiError("Local hostname must contain one non-empty line.")
706
+ try:
707
+ add_deploy_key(normalized_project_ref, key_title, public_key)
708
+ except Exception as exc:
709
+ raise ApiError(f"Project deploy-key registration failed: {exc}") from exc
710
+
711
+ if not keypair_existed:
712
+ register_key()
713
+ else:
714
+ try:
715
+ verify_access(env)
716
+ return key_path, public_key, env
717
+ except RuntimeError:
718
+ register_key()
719
+
720
+ try:
721
+ verify_access(env)
722
+ except RuntimeError as exc:
723
+ raise ApiError(str(exc)) from exc
724
+ return key_path, public_key, env
725
+
726
+
677
727
  def _project_identity_value(project: dict) -> str:
678
728
  """Return the logical Project public UID."""
679
729
  return str(project.get("uid") or "").strip()
@@ -7461,7 +7511,7 @@ def project_create_cmd(
7461
7511
  project_name:
7462
7512
  Project name. If omitted, prompt is shown.
7463
7513
  project_type:
7464
- Project type used for the initial ProjectBranch.
7514
+ Immutable type of the logical Project, shared by every ProjectBranch.
7465
7515
  default_metatables_data_source_uid:
7466
7516
  Required logical Project default MetaTables data source UID. It is
7467
7517
  assigned automatically to the initial main ProjectBranch.
@@ -9786,7 +9836,7 @@ def project_set_up_locally(
9786
9836
  Clone a project locally and provision runtime `.env`.
9787
9837
 
9788
9838
  Workflow:
9789
- - ensure an SSH key and optionally register it through the owning Project,
9839
+ - ensure, register when needed, and verify a repository-specific SSH key,
9790
9840
  - clone repository into local projects root,
9791
9841
  - build local runtime auth/backend entries for the active auth mode,
9792
9842
  - write/update `.env` with local runtime values.
@@ -9853,24 +9903,24 @@ def project_set_up_locally(
9853
9903
  target_dir = projects_root / f"{name}-{project_uid}"
9854
9904
  projects_root.mkdir(parents=True, exist_ok=True)
9855
9905
 
9856
- key_path, _pub_path, pub = ensure_key_for_repo(repo)
9857
- copied = _copy_clipboard(pub)
9906
+ if target_dir.exists():
9907
+ warn(f"Target already exists: {target_dir}")
9908
+ raise typer.Exit(2)
9858
9909
 
9859
- # Best-effort Project-owned deploy key (do not fail set-up-locally on this)
9860
9910
  try:
9861
- host = platform.node()
9862
- add_deploy_key(project_uid, host, pub)
9863
- except Exception as e:
9864
- warn(f"Could not add deploy key automatically (continuing): {e}")
9911
+ key_path, pub, _git_env = _ensure_project_repository_ssh_access(
9912
+ origin=repo,
9913
+ project_ref=project_uid,
9914
+ verify_access=lambda env: verify_git_remote_access(repo, env),
9915
+ )
9916
+ except (ApiError, RuntimeError, ValueError) as exc:
9917
+ error(f"Repository SSH key setup failed: {exc}")
9918
+ raise typer.Exit(1) from exc
9865
9919
 
9920
+ copied = _copy_clipboard(pub)
9866
9921
  agent_env = start_agent_and_add_key(key_path)
9867
9922
 
9868
- if target_dir.exists():
9869
- warn(f"Target already exists: {target_dir}")
9870
- raise typer.Exit(2)
9871
-
9872
- env = os.environ.copy() | agent_env
9873
- env["GIT_SSH_COMMAND"] = f'ssh -i "{str(key_path)}" -o IdentitiesOnly=yes'
9923
+ env = git_ssh_environment(key_path, base_env=os.environ.copy() | agent_env)
9874
9924
 
9875
9925
  with status(f"Cloning repo into {target_dir}..."):
9876
9926
  rc = subprocess.call(
@@ -10033,7 +10083,16 @@ def project_open_signed_terminal(
10033
10083
 
10034
10084
  origin = git_origin(project_dir)
10035
10085
  name = repo_name_from_git_url(origin) or project_dir.name
10036
- key_path, _, _ = ensure_key_for_repo(origin) # creates if missing
10086
+ project_ref = str(project_id or _read_project_ref_from_env_file(project_dir) or "").strip()
10087
+ try:
10088
+ key_path, _public_key, _git_env = _ensure_project_repository_ssh_access(
10089
+ origin=origin,
10090
+ project_ref=project_ref or None,
10091
+ verify_access=lambda env: verify_git_remote_access(origin, env),
10092
+ )
10093
+ except (ApiError, RuntimeError, ValueError) as exc:
10094
+ error(f"Repository SSH key setup failed: {exc}")
10095
+ raise typer.Exit(1) from exc
10037
10096
  open_signed_terminal(str(project_dir), key_path, name)
10038
10097
 
10039
10098
 
@@ -10300,18 +10359,23 @@ def project_sync(
10300
10359
  project_id: str | None = typer.Argument(None, help="Project UID"),
10301
10360
  path: str | None = typer.Option(None, "--path", help="Project directory"),
10302
10361
  message_opt: str | None = typer.Option(None, "--message", "-m", help="Git commit message"),
10303
- dry_run: bool = typer.Option(False, "--dry-run", help="Print steps but do not execute"),
10362
+ dry_run: bool = typer.Option(
10363
+ False,
10364
+ "--dry-run",
10365
+ help="Run read-only preflight and print steps without generating keys or changing the project",
10366
+ ),
10304
10367
  ):
10305
10368
  """
10306
10369
  Run end-to-end sync workflow for project dependencies and git state.
10307
10370
 
10308
10371
  Workflow:
10309
- 1. patch the package version via `uv version`,
10310
- 2. request the backend-owned default tag for this ProjectBranch,
10311
- 3. run `uv lock` + `uv sync`,
10312
- 4. export locked `requirements.txt`,
10313
- 5. commit the changes and create that annotated tag,
10314
- 6. push the branch and tag with `--follow-tags`.
10372
+ 1. preview the patch version and backend-owned ProjectBranch tag,
10373
+ 2. reject local and remote collisions before mutating the project,
10374
+ 3. apply and verify the patch version via `uv version`,
10375
+ 4. run `uv lock` + `uv sync`,
10376
+ 5. export locked `requirements.txt`,
10377
+ 6. commit the changes and create that annotated tag,
10378
+ 7. atomically push the branch and tag.
10315
10379
 
10316
10380
  Parameters
10317
10381
  ----------
@@ -10322,7 +10386,8 @@ def project_sync(
10322
10386
  path:
10323
10387
  Explicit local path.
10324
10388
  dry_run:
10325
- Print plan without executing commands.
10389
+ Run read-only preflight and print the plan without generating keys or
10390
+ changing project files, dependencies, Git state, or backend state.
10326
10391
 
10327
10392
  Examples
10328
10393
  --------
@@ -10368,46 +10433,90 @@ def project_sync(
10368
10433
  f"Git branch {git_branch!r} to ProjectBranch {project_branch_uid}."
10369
10434
  )
10370
10435
 
10371
- ensure_venv(project_dir)
10372
-
10373
10436
  origin = git_origin(project_dir)
10374
10437
  repo_name = repo_name_from_git_url(origin) or project_dir.name
10375
- key_path, _, _ = ensure_key_for_repo(origin)
10438
+ try:
10439
+ require_ssh_git_origin(origin)
10440
+ except ValueError as exc:
10441
+ error(f"Project sync preflight failed: {exc}")
10442
+ raise typer.Exit(1) from exc
10443
+
10444
+ try:
10445
+ ensure_venv(project_dir)
10446
+ uv = ensure_uv_installed(project_dir)
10447
+ current_version = uv_project_version(uv, cwd=project_dir)
10448
+ next_version = uv_preview_patch_version(uv, cwd=project_dir)
10449
+ tag = render_project_branch_default_redeployment_tag(
10450
+ project_branch_uid,
10451
+ version=next_version,
10452
+ )
10453
+ verify_git_tag_absent(project_dir, tag)
10454
+ except (ApiError, RuntimeError) as exc:
10455
+ error(f"Project sync tag preflight failed: {exc}")
10456
+ raise typer.Exit(1) from exc
10376
10457
 
10377
10458
  steps = [
10378
- "resolve uv executable",
10379
- "uv version --bump patch",
10459
+ "preview uv patch version",
10380
10460
  "request backend default redeployment tag",
10461
+ "verify backend tag does not exist locally",
10462
+ "ensure repository SSH key",
10463
+ "register a new or inaccessible SSH key through the owning Project",
10464
+ "git push --dry-run --follow-tags origin HEAD:refs/heads/<branch>",
10465
+ "verify exact backend tag does not exist remotely",
10466
+ "uv version --bump patch",
10467
+ "verify bumped version matches the preflight version",
10381
10468
  "uv lock",
10382
10469
  "uv sync",
10383
10470
  "uv export (locked) -> requirements.txt",
10384
10471
  "git add -A",
10385
10472
  f'git commit -m "{safe_message}"',
10386
10473
  "git tag -a <backend tag> -m <backend tag>",
10387
- "git push --follow-tags",
10474
+ "git push --atomic --follow-tags origin HEAD:refs/heads/<branch> refs/tags/<backend tag>:refs/tags/<backend tag>",
10388
10475
  ]
10389
10476
 
10477
+ print_kv(
10478
+ "Sync release",
10479
+ [
10480
+ ("Current version", current_version),
10481
+ ("Next version", next_version),
10482
+ ("Branch tag", tag),
10483
+ ],
10484
+ )
10390
10485
  print_table("Sync plan", ["Step"], [[s] for s in steps])
10391
10486
 
10392
10487
  if dry_run:
10393
- warn("Dry run: no commands executed.")
10488
+ warn("Dry run: read-only preflight complete; no changes made.")
10394
10489
  return
10395
10490
 
10396
- env = os.environ.copy()
10397
- env["GIT_SSH_COMMAND"] = f'ssh -i "{str(key_path)}" -o IdentitiesOnly=yes'
10491
+ try:
10492
+ _key_path, _public_key, env = _ensure_project_repository_ssh_access(
10493
+ origin=origin,
10494
+ project_ref=project_ref,
10495
+ verify_access=lambda ssh_env: verify_git_push_access(
10496
+ project_dir,
10497
+ git_branch,
10498
+ ssh_env,
10499
+ ),
10500
+ )
10501
+ except (ApiError, RuntimeError, ValueError) as exc:
10502
+ error(f"Project sync SSH preflight failed: {exc}")
10503
+ raise typer.Exit(1) from exc
10504
+
10505
+ try:
10506
+ verify_git_remote_tag_absent(project_dir, tag, env)
10507
+ except RuntimeError as exc:
10508
+ error(f"Project sync remote tag preflight failed: {exc}")
10509
+ raise typer.Exit(1) from exc
10398
10510
 
10399
- uv = ensure_uv_installed(project_dir)
10400
10511
  with status("Running uv + git sync steps..."):
10401
10512
  run_uv(uv, ["version", "--bump", "patch"], cwd=project_dir, env=env)
10402
10513
  version = uv_project_version(uv, cwd=project_dir, env=env)
10403
- try:
10404
- tag = render_project_branch_default_redeployment_tag(
10405
- project_branch_uid,
10406
- version=version,
10514
+ if version != next_version:
10515
+ error(
10516
+ f"Project sync version verification failed: uv produced {version}; "
10517
+ f"preflight expected {next_version}."
10407
10518
  )
10408
- except ApiError as exc:
10409
- error(f"Project sync tag resolution failed: {exc}")
10410
- raise typer.Exit(1) from exc
10519
+ raise typer.Exit(1)
10411
10520
  run_uv(uv, ["lock"], cwd=project_dir, env=env)
10412
10521
  run_uv(uv, ["sync"], cwd=project_dir, env=env)
10413
10522
  # `uv sync` can prune ad hoc packages from `.venv`, including a `uv`
@@ -10425,7 +10534,19 @@ def project_sync(
10425
10534
  run_cmd(["git", "add", "-A"], cwd=project_dir, env=env)
10426
10535
  run_cmd(["git", "commit", "-m", safe_message], cwd=project_dir, env=env)
10427
10536
  run_cmd(["git", "tag", "-a", tag, "-m", tag], cwd=project_dir, env=env)
10428
- run_cmd(["git", "push", "--follow-tags"], cwd=project_dir, env=env)
10537
+ run_cmd(
10538
+ [
10539
+ "git",
10540
+ "push",
10541
+ "--atomic",
10542
+ "--follow-tags",
10543
+ "origin",
10544
+ f"HEAD:refs/heads/{git_branch}",
10545
+ f"refs/tags/{tag}:refs/tags/{tag}",
10546
+ ],
10547
+ cwd=project_dir,
10548
+ env=env,
10549
+ )
10429
10550
 
10430
10551
  success(f"Synced: {repo_name}")
10431
10552
 
@@ -11,6 +11,7 @@ Local operations shared by several commands:
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import json
14
15
  import os
15
16
  import pathlib
16
17
  import shutil
@@ -122,6 +123,44 @@ def uv_project_version(
122
123
  return version.splitlines()[-1].strip()
123
124
 
124
125
 
126
+ def uv_preview_patch_version(
127
+ uv_path: pathlib.Path,
128
+ cwd: pathlib.Path,
129
+ env: dict[str, str] | None = None,
130
+ ) -> str:
131
+ """Return the version ``uv version --bump patch`` would produce without mutation."""
132
+ result = subprocess.run(
133
+ [
134
+ str(uv_path),
135
+ "version",
136
+ "--bump",
137
+ "patch",
138
+ "--dry-run",
139
+ "--output-format",
140
+ "json",
141
+ ],
142
+ cwd=str(cwd),
143
+ env=env,
144
+ capture_output=True,
145
+ text=True,
146
+ )
147
+ if result.returncode != 0:
148
+ detail = (result.stderr or result.stdout or "").strip()
149
+ suffix = f": {detail}" if detail else ""
150
+ raise RuntimeError(
151
+ f"Command failed ({result.returncode}): {uv_path} version --bump patch "
152
+ f"--dry-run --output-format json{suffix}"
153
+ )
154
+ try:
155
+ payload = json.loads(result.stdout or "")
156
+ except json.JSONDecodeError as exc:
157
+ raise RuntimeError("uv version patch preview returned invalid JSON.") from exc
158
+ version = payload.get("version") if isinstance(payload, dict) else None
159
+ if not isinstance(version, str) or not version.strip():
160
+ raise RuntimeError("uv version patch preview returned no project version.")
161
+ return version.strip()
162
+
163
+
125
164
  def git_origin(project_dir: pathlib.Path) -> str:
126
165
  """
127
166
  Return git remote origin URL.