sanka-cli 0.2.3__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/PKG-INFO +32 -1
  2. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/README.md +30 -0
  3. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/pyproject.toml +2 -1
  4. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/__init__.py +44 -23
  5. sanka_cli-0.2.5/src/sanka/cli/_compact.py +50 -0
  6. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/lifecycle.py +1 -2
  7. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/__init__.py +1 -1
  8. sanka_cli-0.2.5/src/sanka_cli/certificates.py +98 -0
  9. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/client.py +28 -1
  10. sanka_cli-0.2.5/src/sanka_cli/commands/cloud.py +550 -0
  11. sanka_cli-0.2.5/src/sanka_cli/commands/cloud_fleet.py +262 -0
  12. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/main.py +2 -0
  13. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/runtime.py +8 -1
  14. sanka_cli-0.2.5/src/sanka_cli/skills/sanka-cli/SKILL.md +48 -0
  15. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_cli.py +1 -1
  16. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_cli_developer_experience.py +66 -1
  17. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_lifecycle.py +1 -0
  18. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mcp.py +2 -2
  19. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_sanka_facade.py +1 -1
  20. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_skill_commands.py +7 -1
  21. sanka_cli-0.2.3/src/sanka_cli/skills/sanka-cli/SKILL.md +0 -62
  22. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/.gitignore +0 -0
  23. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/LICENSES/AGPL-3.0-only.txt +0 -0
  24. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/LICENSES/Apache-2.0.txt +0 -0
  25. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/NOTICE +0 -0
  26. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/__init__.py +0 -0
  27. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/_client.py +0 -0
  28. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/_output.py +0 -0
  29. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/_research.py +0 -0
  30. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/py.typed +0 -0
  31. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/connector/__init__.py +0 -0
  32. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/connector/py.typed +0 -0
  33. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/__about__.py +0 -0
  34. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/__init__.py +0 -0
  35. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/concurrency.py +0 -0
  36. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/connector_client.py +0 -0
  37. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/connector_host.py +0 -0
  38. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/engine.py +0 -0
  39. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/__init__.py +0 -0
  40. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/continuous.py +0 -0
  41. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/errors.py +0 -0
  42. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/local.py +0 -0
  43. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/model.py +0 -0
  44. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/report_codec.py +0 -0
  45. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/routes.py +0 -0
  46. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/scope.py +0 -0
  47. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/state.py +0 -0
  48. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/validate.py +0 -0
  49. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/__init__.py +0 -0
  50. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/discovery.py +0 -0
  51. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/model.py +0 -0
  52. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/runner.py +0 -0
  53. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/store.py +0 -0
  54. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/hashing.py +0 -0
  55. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/__init__.py +0 -0
  56. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/errors.py +0 -0
  57. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
  58. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/model.py +0 -0
  59. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
  60. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
  61. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/record_mapping.py +0 -0
  62. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/planner.py +0 -0
  63. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/private_sqlite.py +0 -0
  64. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/py.typed +0 -0
  65. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/registry.py +0 -0
  66. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/safe_local_io.py +0 -0
  67. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/spec.py +0 -0
  68. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/state.py +0 -0
  69. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/__main__.py +0 -0
  70. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/bundle.py +0 -0
  71. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/__init__.py +0 -0
  72. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/ai.py +0 -0
  73. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/auth.py +0 -0
  74. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/cloud_migrate.py +0 -0
  75. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/code.py +0 -0
  76. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/migrate.py +0 -0
  77. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/profiles.py +0 -0
  78. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/resources.py +0 -0
  79. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/skill.py +0 -0
  80. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/workflows.py +0 -0
  81. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/config.py +0 -0
  82. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/__init__.py +0 -0
  83. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/client.py +0 -0
  84. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/render.py +0 -0
  85. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/server.py +0 -0
  86. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/output.py +0 -0
  87. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/state.py +0 -0
  88. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/__about__.py +0 -0
  89. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/__init__.py +0 -0
  90. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/credentials.py +0 -0
  91. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/errors.py +0 -0
  92. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/protocols.py +0 -0
  93. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/provisioning.py +0 -0
  94. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/py.typed +0 -0
  95. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/records.py +0 -0
  96. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/registration.py +0 -0
  97. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/schema.py +0 -0
  98. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/conftest.py +0 -0
  99. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
  100. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
  101. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/api/views.py +0 -0
  102. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
  103. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
  104. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
  105. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/manage.py +0 -0
  106. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
  107. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
  108. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_code_bundle.py +0 -0
  109. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_code_commands.py +0 -0
  110. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_concurrency.py +0 -0
  111. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_connector_client.py +0 -0
  112. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_connector_host.py +0 -0
  113. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_credential_provider.py +0 -0
  114. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_e2e.py +0 -0
  115. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_execution_adoption.py +0 -0
  116. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
  117. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_postgres_e2e.py +0 -0
  118. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_continuous.py +0 -0
  119. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_exact_scope.py +0 -0
  120. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_local_store.py +0 -0
  121. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_report_codec.py +0 -0
  122. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_routes.py +0 -0
  123. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_scope.py +0 -0
  124. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_seam.py +0 -0
  125. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_validate.py +0 -0
  126. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_discovery.py +0 -0
  127. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_runner.py +0 -0
  128. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_store.py +0 -0
  129. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_wheel_acceptance.py +0 -0
  130. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_hashing.py +0 -0
  131. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_hosted_cli.py +0 -0
  132. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_candidates.py +0 -0
  133. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_owner_mapping.py +0 -0
  134. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_pending_relationships.py +0 -0
  135. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_record_mapping.py +0 -0
  136. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_planner.py +0 -0
  137. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_registry.py +0 -0
  138. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_safe_local_io.py +0 -0
  139. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_spec.py +0 -0
  140. {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_state_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sanka-cli
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Sanka command-line interface and migration runtime
5
5
  Project-URL: Repository, https://github.com/sankaHQ/sanka
6
6
  Project-URL: Issues, https://github.com/sankaHQ/sanka/issues
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Typing :: Typed
17
17
  Requires-Python: >=3.12
18
18
  Requires-Dist: click<9,>=8.1
19
+ Requires-Dist: cryptography<52,>=43
19
20
  Requires-Dist: httpx<1,>=0.27
20
21
  Requires-Dist: keyring<26,>=25
21
22
  Requires-Dist: packaging<27,>=24
@@ -107,6 +108,17 @@ workflows, AI, Custom Code, and explicitly cloud-selected migrations retain
107
108
  their existing authentication requirements. Add `--json` to local lifecycle
108
109
  commands for the stable `sanka-cli/v1` response envelope.
109
110
 
111
+ ## Hosted repository runs
112
+
113
+ `sanka cloud` uploads reviewed repository ZIPs, starts runs with an explicit
114
+ credit cap, and reads status, receipts, and retained artifacts. When enabled by
115
+ the operator, `cloud repair` makes one bounded patch attempt and `cloud certify`
116
+ compares selected HTTP cases in a fresh worker. `cloud certificate-verify`
117
+ verifies signed evidence online or with an explicitly trusted offline key ring.
118
+ Every hosted command pins its workspace; paid operations require confirmation.
119
+ See [Hosted repository runs](../../docs/cloud-runs.md) for inputs, pricing,
120
+ verification scope, and revocation limits.
121
+
110
122
  ## Extension trust
111
123
 
112
124
  The manager verifies a trusted marketplace snapshot, manifest schema,
@@ -149,3 +161,22 @@ The distribution license expression is
149
161
  `Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
150
162
  CLI notice ship in the wheel and source archive. File-level SPDX identifiers
151
163
  are authoritative.
164
+
165
+
166
+ ### Compact lifecycle output
167
+
168
+ Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
169
+ `verify`, and extension management commands when an agent needs a short result.
170
+ The flags are mutually exclusive; `--json` retains its existing contract.
171
+ Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
172
+ then emits one `key=JSON-value` per line. Strings use JSON escaping, including
173
+ newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
174
+ Legacy aliases are not repeated. With full artifacts available, generated file
175
+ contents and route source code are replaced by explicit omission counts and file
176
+ names. Read the returned artifact for those details; a compact summary is not a
177
+ replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
178
+ use that core hash for apply, not a nested extension hash.
179
+
180
+ Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
181
+ manual gaps is not complete. Test establishes generated scope; scenario verification
182
+ checks HTTP and database parity within the supplied fixtures and cases.
@@ -77,6 +77,17 @@ workflows, AI, Custom Code, and explicitly cloud-selected migrations retain
77
77
  their existing authentication requirements. Add `--json` to local lifecycle
78
78
  commands for the stable `sanka-cli/v1` response envelope.
79
79
 
80
+ ## Hosted repository runs
81
+
82
+ `sanka cloud` uploads reviewed repository ZIPs, starts runs with an explicit
83
+ credit cap, and reads status, receipts, and retained artifacts. When enabled by
84
+ the operator, `cloud repair` makes one bounded patch attempt and `cloud certify`
85
+ compares selected HTTP cases in a fresh worker. `cloud certificate-verify`
86
+ verifies signed evidence online or with an explicitly trusted offline key ring.
87
+ Every hosted command pins its workspace; paid operations require confirmation.
88
+ See [Hosted repository runs](../../docs/cloud-runs.md) for inputs, pricing,
89
+ verification scope, and revocation limits.
90
+
80
91
  ## Extension trust
81
92
 
82
93
  The manager verifies a trusted marketplace snapshot, manifest schema,
@@ -119,3 +130,22 @@ The distribution license expression is
119
130
  `Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
120
131
  CLI notice ship in the wheel and source archive. File-level SPDX identifiers
121
132
  are authoritative.
133
+
134
+
135
+ ### Compact lifecycle output
136
+
137
+ Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
138
+ `verify`, and extension management commands when an agent needs a short result.
139
+ The flags are mutually exclusive; `--json` retains its existing contract.
140
+ Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
141
+ then emits one `key=JSON-value` per line. Strings use JSON escaping, including
142
+ newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
143
+ Legacy aliases are not repeated. With full artifacts available, generated file
144
+ contents and route source code are replaced by explicit omission counts and file
145
+ names. Read the returned artifact for those details; a compact summary is not a
146
+ replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
147
+ use that core hash for apply, not a nested extension hash.
148
+
149
+ Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
150
+ manual gaps is not complete. Test establishes generated scope; scenario verification
151
+ checks HTTP and database parity within the supplied fixtures and cases.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sanka-cli"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Sanka command-line interface and migration runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -16,6 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "click>=8.1,<9",
19
+ "cryptography>=43,<52",
19
20
  "httpx>=0.27,<1",
20
21
  "keyring>=25,<26",
21
22
  "packaging>=24,<27",
@@ -85,17 +85,21 @@ class _CliArgumentParser(argparse.ArgumentParser):
85
85
 
86
86
  def main(argv: list[str] | None = None) -> int:
87
87
  arguments = list(sys.argv[1:] if argv is None else argv)
88
- json_errors = "--json" in arguments
88
+ json_errors = "--json" in arguments or "--compact-dsl" in arguments
89
89
  parser = _build_parser(json_errors=json_errors)
90
90
  try:
91
91
  args = parser.parse_args(arguments)
92
92
  except CliUsageError as error:
93
93
  command = arguments[0] if arguments and arguments[0] in SDK_COMMANDS else "sanka"
94
94
  return _print_cli_error(
95
- argparse.Namespace(command=command, json=json_errors),
95
+ argparse.Namespace(
96
+ command=command, json=json_errors, compact_dsl="--compact-dsl" in arguments
97
+ ),
96
98
  error,
97
99
  exit_code=2,
98
100
  )
101
+ if getattr(args, "compact_dsl", False):
102
+ args.json = True
99
103
  if args.command is None:
100
104
  parser.print_help()
101
105
  return 0
@@ -173,12 +177,17 @@ def _json_result(
173
177
  return envelope
174
178
 
175
179
 
176
- def _print_json(payload: dict[str, Any]) -> None:
177
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
180
+ def _print_result(args: argparse.Namespace, payload: dict[str, Any]) -> None:
181
+ if getattr(args, "compact_dsl", False):
182
+ from sanka.cli._compact import render_compact
183
+
184
+ print(render_compact(payload))
185
+ else:
186
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
178
187
 
179
188
 
180
189
  def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: int) -> int:
181
- if getattr(args, "json", False):
190
+ if getattr(args, "json", False) or getattr(args, "compact_dsl", False):
182
191
  code = getattr(error, "code", None)
183
192
  details = getattr(error, "details", None)
184
193
  structured_error: dict[str, Any] = {
@@ -187,13 +196,14 @@ def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: i
187
196
  }
188
197
  if isinstance(details, dict) and details:
189
198
  structured_error["details"] = details
190
- _print_json(
199
+ _print_result(
200
+ args,
191
201
  _json_result(
192
202
  str(getattr(args, "command", "sanka") or "sanka"),
193
203
  {"error": structured_error},
194
204
  outcome="error",
195
205
  migration_state="not_started" if exit_code == 2 else "failed",
196
- )
206
+ ),
197
207
  )
198
208
  else:
199
209
  _terminal(args).failure(str(error))
@@ -221,9 +231,15 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
221
231
 
222
232
  def presentation(sub: argparse.ArgumentParser, *, json_option: bool = True) -> None:
223
233
  if json_option:
224
- sub.add_argument(
234
+ formats = sub.add_mutually_exclusive_group()
235
+ formats.add_argument(
225
236
  "--json", action="store_true", help="print one sanka-cli/v1 JSON document"
226
237
  )
238
+ formats.add_argument(
239
+ "--compact-dsl",
240
+ action="store_true",
241
+ help="print a compact result; full generated code stays in artifacts",
242
+ )
227
243
  sub.add_argument("--no-color", action="store_true", help="disable ANSI color")
228
244
  detail = sub.add_mutually_exclusive_group()
229
245
  detail.add_argument("--quiet", action="store_true", help="print only outcome and errors")
@@ -414,7 +430,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
414
430
  description=(
415
431
  "Check hashes, generated files, route coverage, and configured read-only HTTP "
416
432
  "comparisons. With --scenarios, replay a scenario file against the source "
417
- "application and a candidate FastAPI app and diff status, body, declared "
433
+ "application and a candidate FastAPI or Flask app and diff status, body, declared "
418
434
  "headers, and database state; that mode needs no plan or generated manifest."
419
435
  ),
420
436
  epilog=(
@@ -436,6 +452,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
436
452
  help="application repository root",
437
453
  )
438
454
  verify.add_argument("--to", help="select an application plan")
455
+ verify.add_argument("--settings", help="source Django settings module for scenario replay")
439
456
  verify.add_argument("--artifact-dir", default=DEFAULT_ARTIFACT_DIR)
440
457
  verify.add_argument("--output", default=None, help="generated FastAPI output directory")
441
458
  verify.add_argument(
@@ -667,8 +684,8 @@ def _extension_result(
667
684
  records: list[dict[str, Any]],
668
685
  ) -> int:
669
686
  data = {"operation": operation, "records": records}
670
- if args.json:
671
- _print_json(_json_result("extension", data, migration_state="not_started"))
687
+ if args.json or getattr(args, "compact_dsl", False):
688
+ _print_result(args, _json_result("extension", data, migration_state="not_started"))
672
689
  return 0
673
690
 
674
691
  terminal = _terminal(args)
@@ -895,8 +912,9 @@ def _print_application_result(
895
912
  *,
896
913
  migration_state: str,
897
914
  ) -> int:
898
- if args.json:
899
- _print_json(
915
+ if args.json or getattr(args, "compact_dsl", False):
916
+ _print_result(
917
+ args,
900
918
  _json_result(
901
919
  command,
902
920
  result.data,
@@ -904,7 +922,7 @@ def _print_application_result(
904
922
  artifacts=list(result.artifacts),
905
923
  limitations=list(result.limitations),
906
924
  next_actions=list(result.next_actions),
907
- )
925
+ ),
908
926
  )
909
927
  else:
910
928
  terminal = _terminal(args)
@@ -926,8 +944,9 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
926
944
  engine = _engine(args.state)
927
945
  run_id = engine.create(spec)
928
946
  migration_plan = await engine.plan(run_id)
929
- if args.json:
930
- _print_json(
947
+ if args.json or getattr(args, "compact_dsl", False):
948
+ _print_result(
949
+ args,
931
950
  _json_result(
932
951
  "plan",
933
952
  {
@@ -953,7 +972,7 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
953
972
  ]
954
973
  )
955
974
  ],
956
- )
975
+ ),
957
976
  )
958
977
  else:
959
978
  _print_plan(run_id, migration_plan)
@@ -995,8 +1014,9 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
995
1014
  if run.plan_json is None:
996
1015
  raise ExecutionError("no plan for this spec yet; run `sanka plan` first")
997
1016
  await engine.apply(run_id, plan_hash=args.plan_hash)
998
- if args.json:
999
- _print_json(
1017
+ if args.json or getattr(args, "compact_dsl", False):
1018
+ _print_result(
1019
+ args,
1000
1020
  _json_result(
1001
1021
  "apply",
1002
1022
  {
@@ -1017,7 +1037,7 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
1017
1037
  ]
1018
1038
  )
1019
1039
  ],
1020
- )
1040
+ ),
1021
1041
  )
1022
1042
  else:
1023
1043
  print(f"run {run_id}: applied")
@@ -1053,8 +1073,9 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
1053
1073
  engine = _engine(args.state)
1054
1074
  run_id = engine.create(spec)
1055
1075
  verify_report = await engine.verify(run_id)
1056
- if args.json:
1057
- _print_json(
1076
+ if args.json or getattr(args, "compact_dsl", False):
1077
+ _print_result(
1078
+ args,
1058
1079
  _json_result(
1059
1080
  "verify",
1060
1081
  {
@@ -1077,7 +1098,7 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
1077
1098
  "verified_within_scope" if verify_report.ok else "verification_failed"
1078
1099
  ),
1079
1100
  artifacts=[str(Path(args.state).resolve())],
1080
- )
1101
+ ),
1081
1102
  )
1082
1103
  else:
1083
1104
  _print_verify(verify_report)
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ """Model-facing presentation of the existing result, with JSON-escaped values."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ def _summary(value: Any, *, path: tuple[str, ...] = (), artifacts: bool = False) -> Any:
11
+ if isinstance(value, dict):
12
+ if path in {("files",), ("extension", "files")} and artifacts:
13
+ return {"names": sorted(value), "omitted_file_contents": len(value)}
14
+ result = {}
15
+ for name, item in value.items():
16
+ if name == "extension" and isinstance(item, dict):
17
+ # Core lifecycle responses repeat extension fields; retain differing hashes.
18
+ item = {k: v for k, v in item.items() if k not in value or value[k] != v}
19
+ if not item:
20
+ continue
21
+ if (
22
+ name == "source"
23
+ and {"classification", "method"} <= value.keys()
24
+ and isinstance(item, str)
25
+ and artifacts
26
+ ):
27
+ result["omitted_source_characters"] = len(item)
28
+ else:
29
+ result[name] = _summary(item, path=(*path, name), artifacts=artifacts)
30
+ return result
31
+ if isinstance(value, list):
32
+ return [_summary(item, path=(*path, "[]"), artifacts=artifacts) for item in value]
33
+ return value
34
+
35
+
36
+ def render_compact(payload: dict[str, Any]) -> str:
37
+ """One header, then key=JSON lines. Never truncate failures, warnings or hashes."""
38
+ lines = [" ".join(str(payload[k]) for k in ("command", "outcome", "migration_state"))]
39
+ lines[0] = "sanka-compact/v1 " + lines[0]
40
+ data = _summary(payload["data"], artifacts=bool(payload.get("artifacts")))
41
+ for key, value in data.items():
42
+ # Escape even extension-defined keys so a newline cannot forge another record.
43
+ label = key if key.isidentifier() else json.dumps(key, ensure_ascii=False)
44
+ lines.append(label + "=" + json.dumps(value, ensure_ascii=False, separators=(",", ":")))
45
+ for key in ("artifacts", "limitations", "next_actions"):
46
+ if payload.get(key):
47
+ lines.append(
48
+ key + "=" + json.dumps(payload[key], ensure_ascii=False, separators=(",", ":"))
49
+ )
50
+ return "\n".join(lines)
@@ -820,7 +820,7 @@ class ApplicationLifecycle:
820
820
  written, so the source fingerprint legitimately differs from any reviewed plan and
821
821
  a plan may not exist yet. The extension lock still has to match its recommendation
822
822
  exactly; only the plan, fingerprint, and artifact checks that guard apply and test
823
- are skipped, because replay writes nothing into the project.
823
+ are skipped, because replay only writes verification artifacts in its artifact directory.
824
824
  """
825
825
  explicit_env_names = _canonical_environment_names(explicit_env_names)
826
826
  fingerprint = fingerprint_repository(self.project_root)
@@ -839,7 +839,6 @@ class ApplicationLifecycle:
839
839
  if result.outcome != "success":
840
840
  self._raise_failure(result)
841
841
  data = dict(result.data)
842
- data["extension"] = result.data
843
842
  return ExtensionResult(
844
843
  outcome="success",
845
844
  data=data,
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  __all__ = ["__version__"]
3
3
 
4
- __version__ = "0.2.3"
4
+ __version__ = "0.2.5"
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Public certificate input and verification helpers; no hosted execution code."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import unquote, urlsplit
12
+
13
+ from cryptography.exceptions import InvalidSignature
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
15
+
16
+
17
+ def canonical_bytes(value: Any) -> bytes:
18
+ return json.dumps(
19
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
20
+ ).encode("utf-8")
21
+
22
+
23
+ def read_json(path: Path, maximum: int = 192 * 1024) -> Any:
24
+ with path.open("rb") as stream:
25
+ data = stream.read(maximum + 1)
26
+ if len(data) > maximum:
27
+ raise ValueError("JSON file exceeds its size limit")
28
+ return json.loads(data)
29
+
30
+
31
+ def load_scenarios(path: Path) -> list[dict[str, Any]]:
32
+ cases = read_json(path, 48 * 1024)
33
+ if not isinstance(cases, list) or not 1 <= len(cases) <= 50:
34
+ raise ValueError("Cases must be a JSON array containing 1 to 50 requests")
35
+ seen, normalized = set(), []
36
+ for case in cases:
37
+ if not isinstance(case, dict) or set(case) - {"id", "method", "path", "json_body"}:
38
+ raise ValueError("Each case accepts only id, method, path and json_body")
39
+ identifier, method, value = case.get("id"), case.get("method"), case.get("path")
40
+ if (
41
+ not isinstance(identifier, str)
42
+ or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", identifier)
43
+ or identifier in seen
44
+ or method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}
45
+ or not isinstance(value, str)
46
+ or not 1 <= len(value) <= 1000
47
+ ):
48
+ raise ValueError("Each case needs a unique ID, supported method and local HTTP path")
49
+ seen.add(identifier)
50
+ parsed, decoded = urlsplit(value), unquote(value)
51
+ if (
52
+ not value.startswith("/")
53
+ or value.startswith("//")
54
+ or parsed.scheme
55
+ or parsed.netloc
56
+ or parsed.fragment
57
+ or "\\" in decoded
58
+ or any(ord(char) < 32 or ord(char) > 126 for char in decoded)
59
+ or ".." in decoded.split("?", 1)[0].split("/")
60
+ ):
61
+ raise ValueError("Case paths must be local ASCII HTTP paths without traversal")
62
+ body = case.get("json_body")
63
+ if len(canonical_bytes({"body": body})) > 8192 or (method == "GET" and body is not None):
64
+ raise ValueError("Case bodies are limited to 8 KiB; GET cannot have a body")
65
+ normalized.append({"id": identifier, "method": method, "path": value, "json_body": body})
66
+ if len(canonical_bytes(normalized)) > 47 * 1024:
67
+ raise ValueError("The normalized certificate scope exceeds its size limit")
68
+ return normalized
69
+
70
+
71
+ def verify_certificate(document: dict[str, Any], key_document: dict[str, Any]) -> dict[str, Any]:
72
+ """Verify using keys supplied by the caller's chosen trusted source only."""
73
+ signed = document.get("certificate", document)
74
+ key_document = key_document.get("data", key_document)
75
+ try:
76
+ payload = signed["payload"]
77
+ if (
78
+ signed["algorithm"] != "Ed25519"
79
+ or signed["encoding"] != "sorted-ascii-json-v1"
80
+ or payload["schema_version"] != "developer-certificate-v1"
81
+ or payload["issuer"] != "https://api-v2.sanka.com"
82
+ or not isinstance(payload, dict)
83
+ ):
84
+ raise ValueError("Unsupported certificate issuer or signature format")
85
+ matches = [row for row in key_document["keys"] if row["key_id"] == signed["key_id"]]
86
+ if len(matches) != 1 or matches[0].get("algorithm") != "Ed25519":
87
+ raise ValueError("The signing key is not in the selected trusted key ring")
88
+ key = Ed25519PublicKey.from_public_bytes(
89
+ base64.b64decode(matches[0]["public_key_base64"], validate=True)
90
+ )
91
+ key.verify(
92
+ base64.b64decode(signed["signature_base64"], validate=True), canonical_bytes(payload)
93
+ )
94
+ except InvalidSignature as error:
95
+ raise ValueError("Certificate signature is invalid") from error
96
+ except (KeyError, TypeError, AttributeError) as error:
97
+ raise ValueError("Certificate or trusted key ring has an invalid format") from error
98
+ return payload
@@ -87,16 +87,43 @@ class SankaApiClient:
87
87
  path: str,
88
88
  *,
89
89
  params: dict[str, Any] | None = None,
90
+ headers: dict[str, str] | None = None,
91
+ max_bytes: int | None = None,
90
92
  ) -> tuple[bytes, dict[str, str]]:
91
93
  """Fetch a non-JSON body (currently only the Custom Code bundle download).
92
94
 
93
95
  Returns headers alongside the payload so the caller can verify the digest the
94
96
  server reports without a second request.
95
97
  """
98
+ request_headers = self._headers()
99
+ request_headers.update(headers or {})
100
+ if max_bytes is not None:
101
+ with self.client.stream(
102
+ method.upper(),
103
+ path,
104
+ headers=request_headers,
105
+ params=params,
106
+ ) as response:
107
+ content = bytearray()
108
+ for chunk in response.iter_bytes(chunk_size=65536):
109
+ if len(content) + len(chunk) > max_bytes:
110
+ raise APIError(
111
+ status_code=413, message="API artifact exceeds its size limit"
112
+ )
113
+ content.extend(chunk)
114
+ if response.status_code >= 400:
115
+ self._raise_for_response(
116
+ httpx.Response(
117
+ response.status_code,
118
+ content=bytes(content),
119
+ headers=response.headers,
120
+ )
121
+ )
122
+ return bytes(content), dict(response.headers)
96
123
  response = self.client.request(
97
124
  method.upper(),
98
125
  path,
99
- headers=self._headers(),
126
+ headers=request_headers,
100
127
  params=params,
101
128
  )
102
129
  if response.status_code >= 400: