ixt-cli 0.8.1__tar.gz → 0.9.1__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 (159) hide show
  1. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/.gitignore +1 -0
  2. ixt_cli-0.9.1/LICENSE +21 -0
  3. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/PKG-INFO +57 -28
  4. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/README.md +55 -27
  5. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/pyproject.toml +4 -7
  6. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/backends/binary.py +148 -28
  7. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/backends/node.py +2 -4
  8. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_apply.py +54 -7
  9. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_config.py +69 -51
  10. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_install.py +5 -2
  11. ixt_cli-0.9.1/src/ixt/cli/cmd_lock.py +89 -0
  12. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/commands.py +3 -0
  13. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/parser.py +28 -0
  14. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/asset_pattern_cache.py +2 -12
  15. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/env_policy.py +7 -2
  16. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/flags.py +3 -3
  17. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/heuristics.py +129 -1
  18. ixt_cli-0.9.1/src/ixt/config/lockfile.py +279 -0
  19. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/models.py +3 -12
  20. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/apply.py +176 -7
  21. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/cache.py +45 -25
  22. ixt_cli-0.9.1/src/ixt/core/discover.py +322 -0
  23. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/expose.py +106 -42
  24. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/extract.py +84 -12
  25. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/inject.py +51 -20
  26. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/install.py +72 -78
  27. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/install_local.py +51 -63
  28. ixt_cli-0.9.1/src/ixt/core/lockfile.py +698 -0
  29. ixt_cli-0.9.1/src/ixt/core/locks.py +112 -0
  30. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/pathlink.py +52 -1
  31. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/resolve_cache.py +4 -12
  32. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/save.py +62 -25
  33. ixt_cli-0.9.1/src/ixt/core/transactions.py +216 -0
  34. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/upgrade.py +22 -106
  35. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/data/heuristics.toml +9 -3
  36. ixt_cli-0.9.1/src/ixt/libs/atomic.py +56 -0
  37. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/net/gitlab_api.py +8 -8
  38. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/net/http.py +86 -3
  39. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/net/npm.py +18 -2
  40. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/net/pypi.py +12 -0
  41. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/conftest.py +54 -20
  42. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/README.md +6 -4
  43. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/docker/Dockerfile.alpine +1 -1
  44. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/docker/Dockerfile.fedora +1 -1
  45. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/docker/Dockerfile.ubuntu +1 -1
  46. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/run_e2e_incus.sh +1 -1
  47. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/test_e2e.py +18 -8
  48. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/integration/test_lifecycle.py +3 -1
  49. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_apply.py +409 -2
  50. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_argparse_completion.py +1 -1
  51. ixt_cli-0.9.1/tests/unit/test_atomic.py +61 -0
  52. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_binary_backend.py +467 -40
  53. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_cache.py +49 -2
  54. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_cli.py +49 -1
  55. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_discover.py +186 -0
  56. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_env_policy.py +18 -0
  57. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_export.py +1 -0
  58. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_expose.py +76 -0
  59. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_extract.py +60 -0
  60. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_gitlab_api.py +41 -0
  61. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_heuristics.py +80 -0
  62. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_http.py +127 -0
  63. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_install.py +135 -1
  64. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_install_local.py +30 -4
  65. ixt_cli-0.9.1/tests/unit/test_lock_write.py +863 -0
  66. ixt_cli-0.9.1/tests/unit/test_lockfile.py +195 -0
  67. ixt_cli-0.9.1/tests/unit/test_pathlink.py +145 -0
  68. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_save.py +46 -0
  69. ixt_cli-0.9.1/tests/unit/test_transactions.py +92 -0
  70. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_upgrade.py +33 -20
  71. ixt_cli-0.8.1/src/ixt/core/discover.py +0 -150
  72. ixt_cli-0.8.1/src/ixt/core/locks.py +0 -54
  73. ixt_cli-0.8.1/tests/unit/test_pathlink.py +0 -62
  74. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/__init__.py +0 -0
  75. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/__main__.py +0 -0
  76. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/backends/__init__.py +0 -0
  77. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/backends/binary_resolver.py +0 -0
  78. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/backends/python.py +0 -0
  79. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/__init__.py +0 -0
  80. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/argparse_completion.py +0 -0
  81. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_cache.py +0 -0
  82. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_info.py +0 -0
  83. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_misc.py +0 -0
  84. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_registry.py +0 -0
  85. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/cmd_upgrade.py +0 -0
  86. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/cli/render.py +0 -0
  87. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/__init__.py +0 -0
  88. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/asset_index.py +0 -0
  89. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/fs_policy.py +0 -0
  90. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/registry.py +0 -0
  91. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/settings.py +0 -0
  92. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/setup_toml.py +0 -0
  93. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/config/toml.py +0 -0
  94. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/__init__.py +0 -0
  95. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/apply_actions.py +0 -0
  96. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/backend.py +0 -0
  97. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/bootstrap.py +0 -0
  98. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/doctor.py +0 -0
  99. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/export.py +0 -0
  100. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/hooks.py +0 -0
  101. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/identity.py +0 -0
  102. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/resolution_stats.py +0 -0
  103. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/resolve.py +0 -0
  104. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/runtimes.py +0 -0
  105. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/setup_completions.py +0 -0
  106. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/core/setup_path.py +0 -0
  107. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/data/__init__.py +0 -0
  108. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/data/asset_index.json +0 -0
  109. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/data/registry.toml +0 -0
  110. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/__init__.py +0 -0
  111. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/constants.py +0 -0
  112. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/fmt.py +0 -0
  113. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/logger.py +0 -0
  114. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/output.py +0 -0
  115. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/req_spec.py +0 -0
  116. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/semver.py +0 -0
  117. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/shell.py +0 -0
  118. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/libs/style.py +0 -0
  119. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/net/__init__.py +0 -0
  120. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/net/github_api.py +0 -0
  121. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/net/source.py +0 -0
  122. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/platform/__init__.py +0 -0
  123. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/src/ixt/platform/win.py +0 -0
  124. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/__init__.py +0 -0
  125. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/conftest.py +0 -0
  126. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/playground_incus.sh +0 -0
  127. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/run_e2e_docker.sh +0 -0
  128. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/e2e/runtime_matrix.sh +0 -0
  129. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/integration/__init__.py +0 -0
  130. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/integration/test_policy_runtime.py +0 -0
  131. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/__init__.py +0 -0
  132. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_asset_index.py +0 -0
  133. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_asset_pattern_cache.py +0 -0
  134. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_backend.py +0 -0
  135. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_bootstrap.py +0 -0
  136. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_doctor.py +0 -0
  137. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_flags.py +0 -0
  138. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_fs_policy.py +0 -0
  139. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_github_api.py +0 -0
  140. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_hooks.py +0 -0
  141. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_inject.py +0 -0
  142. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_models.py +0 -0
  143. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_namespacing.py +0 -0
  144. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_node_backend.py +0 -0
  145. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_output.py +0 -0
  146. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_platform.py +0 -0
  147. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_python_backend.py +0 -0
  148. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_registry.py +0 -0
  149. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_req_spec.py +0 -0
  150. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_resolve.py +0 -0
  151. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_resolve_cache.py +0 -0
  152. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_runtimes.py +0 -0
  153. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_semver.py +0 -0
  154. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_settings.py +0 -0
  155. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_setup_path.py +0 -0
  156. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_setup_toml.py +0 -0
  157. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_source.py +0 -0
  158. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_style.py +0 -0
  159. {ixt_cli-0.8.1 → ixt_cli-0.9.1}/tests/unit/test_toml.py +0 -0
@@ -150,3 +150,4 @@ ixt.egg-info
150
150
 
151
151
  .memsearch/
152
152
  .pyscn/
153
+ .pi/
ixt_cli-0.9.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gaëtan Montury
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ixt-cli
3
- Version: 0.8.1
3
+ Version: 0.9.1
4
4
  Summary: Universal CLI tool isolator - manage Python, Node, and binary tools with per-tool isolation
5
5
  Project-URL: Homepage, https://gitlab.com/pytgaen-group/ixt
6
6
  Project-URL: Repository, https://gitlab.com/pytgaen-group/ixt.git
@@ -8,6 +8,7 @@ Project-URL: Issues, https://gitlab.com/pytgaen-group/ixt/-/issues
8
8
  Project-URL: Documentation, https://ixt-39f604.gitlab.io
9
9
  Author-email: Gaëtan Montury <10528250-pytgaen@users.noreply.gitlab.com>
10
10
  License: MIT
11
+ License-File: LICENSE
11
12
  Keywords: cli,github,isolation,npm,package-manager,pypi,tool
12
13
  Classifier: Development Status :: 4 - Beta
13
14
  Classifier: Environment :: Console
@@ -33,8 +34,10 @@ Description-Content-Type: text/markdown
33
34
  <h1 align="center">ixt — Universal CLI Tool Isolator</h1>
34
35
 
35
36
  <p align="center">
36
- <a href="https://gitlab.com/pytgaen-group/ixt/-/releases"><img src="https://img.shields.io/gitlab/v/release/pytgaen-group/ixt?label=release" alt="Release"></a>
37
- <a href="https://gitlab.com/pytgaen-group/ixt/-/pipelines"><img src="https://img.shields.io/gitlab/pipeline-status/pytgaen-group/ixt?branch=main" alt="Pipeline"></a>
37
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/tags"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F81169049%2Frepository%2Ftags%3Fper_page%3D1&amp;query=%24%5B0%5D.name&amp;label=release" alt="Release"></a>
38
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/blob/main/.gitlab-ci.yml"><img src="https://img.shields.io/badge/build-GitLab%20CI-blue?logo=gitlab" alt="Build: GitLab CI"></a>
39
+ <a href="https://pypi.org/project/ixt-cli/"><img src="https://img.shields.io/pypi/v/ixt-cli?label=pypi" alt="PyPI"></a>
40
+ <a href="https://pepy.tech/projects/ixt-cli"><img src="https://static.pepy.tech/badge/ixt-cli/month" alt="PyPI Downloads"></a>
38
41
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
39
42
  <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
40
43
  </p>
@@ -48,10 +51,10 @@ Description-Content-Type: text/markdown
48
51
  *Zero third-party deps. Zero system pollution. One command, any ecosystem.*
49
52
 
50
53
  - **Day-one GitHub releases** — install a tool the day it ships, without
51
- waiting for a formula, registry entry, or plugin.
54
+ waiting for a formula, registry entry, or plugin.
52
55
  - **No system runtime needed** — ixt is pure Python stdlib; `uv` and `bun` are
53
- bootstrapped on first use, so the host does not need Node, npm, or a package
54
- manager pre-installed.
56
+ bootstrapped on first use, so the host does not need Node, npm, or a package
57
+ manager pre-installed.
55
58
 
56
59
  ```bash
57
60
  ixt tool install ruff # Python (PyPI)
@@ -183,34 +186,59 @@ ixt tool install sharkdp/fd@10 # latest 10.x.x
183
186
  ixt tool install ruff>=0.5.0 # PEP 508 (Python)
184
187
  ```
185
188
 
189
+ ## 🔒 Declarative locks
190
+
191
+ `ixt.toml` is the human-edited intent: which tools you want, with optional
192
+ version bounds, exposure rules, and injections. `ixt.lock` is generated from
193
+ that intent and records the exact resolved versions and binary release assets.
194
+
195
+ For teams and CI, commit both files:
196
+
197
+ ```bash
198
+ ixt lock write
199
+ ixt lock check
200
+ ixt tool apply --locked --strict
201
+ ```
202
+
203
+ `--locked` replays the exact lock instead of resolving newer versions.
204
+ `--strict` fails before changing the machine if `ixt.toml` and `ixt.lock`
205
+ diverge. The strict preflight is local: it checks the manifest fingerprint,
206
+ declared policy, version constraints, and locked binary metadata without
207
+ contacting PyPI, npm, GitHub, or GitLab. Run `ixt lock write` when you want to
208
+ resolve upstream again and refresh the lock.
209
+
186
210
  ## 🧰 Commands
187
211
 
188
212
  | Command | Description |
189
213
  | ---------------------------------------------- | --------------------------------------------------------------------- |
190
- | `ixt tool add <pkg>` | Add a tool to ixt.toml without installing |
191
- | `ixt tool install <pkg>` | Install a tool in isolation |
192
- | `ixt tool install <pkg> --bare` | Install the env without exposing any binary |
193
- | `ixt tool uninstall <pkg>` | Remove a tool |
194
- | `ixt tool list` | List installed tools |
195
- | `ixt tool info <pkg>` | Show tool details |
196
- | `ixt tool upgrade <pkg>` | Upgrade a tool |
197
- | `ixt tool upgrade --all` | Upgrade all tools |
198
- | `ixt tool apply` | Sync tools from declarative config |
199
- | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
214
+ | `ixt tool add <pkg>` | Add a tool to ixt.toml without installing |
215
+ | `ixt tool install <pkg>` | Install a tool in isolation |
216
+ | `ixt tool install <pkg> --bare` | Install the env without exposing any binary |
217
+ | `ixt tool uninstall <pkg>` | Remove a tool |
218
+ | `ixt tool list` | List installed tools |
219
+ | `ixt tool info <pkg>` | Show tool details |
220
+ | `ixt tool upgrade <pkg>` | Upgrade a tool |
221
+ | `ixt tool upgrade --all` | Upgrade all tools |
222
+ | `ixt tool apply` | Sync tools from declarative config |
223
+ | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
224
+ | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
225
+ | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
226
+ | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
227
+ | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
200
228
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
201
- | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
202
- | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
203
- | `ixt tool config <pkg> uninject <dep>` | Remove an injected package |
204
- | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
205
- | `ixt tool shell <pkg>` | Open a subshell in tool's env |
206
- | `ixt tool where <pkg>` | Print the tool env path |
229
+ | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
230
+ | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
231
+ | `ixt tool config <pkg> uninject <dep>` | Remove an injected package |
232
+ | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
233
+ | `ixt tool shell <pkg>` | Open a subshell in tool's env |
234
+ | `ixt tool where <pkg>` | Print the tool env path |
207
235
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
208
236
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
209
237
  | `ixt cache info` | Show cache paths and sizes |
210
238
  | `ixt cache prune` | Keep current + previous indexed download artifacts per repository |
211
239
  | `ixt cache clear {downloads,metadata,tmp,all}` | Wipe cache(s); never touches installed tools |
212
- | `ixt runtime info` | Show ixt-managed runtime state |
213
- | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
240
+ | `ixt runtime info` | Show ixt-managed runtime state |
241
+ | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
214
242
  | `ixt setup path` | Add `$IXT_HOME/installed/bin` to PATH |
215
243
  | `ixt setup completions --shell zsh` | Print shell completion code for ixt |
216
244
 
@@ -219,7 +247,8 @@ ixt tool install ruff>=0.5.0 # PEP 508 (Python)
219
247
  ```
220
248
  $IXT_HOME/ # default ~/.local/share/ixt (respects $XDG_DATA_HOME)
221
249
  ├── config/
222
- │ ├── ixt.toml
250
+ │ ├── ixt.toml # global declarative manifest fallback
251
+ │ ├── ixt.lock # global lock when using the global ixt.toml
223
252
  │ ├── registry.toml
224
253
  │ └── heuristics.toml
225
254
  └── installed/
@@ -259,17 +288,17 @@ ixt is **not** a runtime version manager (mise, proto, asdf cover that). It mana
259
288
  git clone https://gitlab.com/pytgaen-group/ixt.git && cd ixt
260
289
  uv sync --group dev
261
290
 
262
- uv run pytest # test suite
291
+ uv run pytest # hermetic unit suite
263
292
  uv run ruff check src/ tests/ # lint
264
293
  uv run ruff format src/ tests/ # format
265
294
  ```
266
295
 
267
296
  ## 🤝 Contributing
268
297
 
269
- Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. The GitHub repository (if present) is a **read-only mirror** — please open issues and merge requests on GitLab.
298
+ Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. Any GitHub repository for this project is a **read-only mirror** of GitLab; it is not the review or issue tracker. Please open issues and merge requests on GitLab.
270
299
 
271
300
  Bug reports and reproduction recipes are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details, or open an issue to start a discussion.
272
301
 
273
302
  ## 📄 License
274
303
 
275
- MIT
304
+ MIT. See [`LICENSE`](LICENSE).
@@ -5,8 +5,10 @@
5
5
  <h1 align="center">ixt — Universal CLI Tool Isolator</h1>
6
6
 
7
7
  <p align="center">
8
- <a href="https://gitlab.com/pytgaen-group/ixt/-/releases"><img src="https://img.shields.io/gitlab/v/release/pytgaen-group/ixt?label=release" alt="Release"></a>
9
- <a href="https://gitlab.com/pytgaen-group/ixt/-/pipelines"><img src="https://img.shields.io/gitlab/pipeline-status/pytgaen-group/ixt?branch=main" alt="Pipeline"></a>
8
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/tags"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F81169049%2Frepository%2Ftags%3Fper_page%3D1&amp;query=%24%5B0%5D.name&amp;label=release" alt="Release"></a>
9
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/blob/main/.gitlab-ci.yml"><img src="https://img.shields.io/badge/build-GitLab%20CI-blue?logo=gitlab" alt="Build: GitLab CI"></a>
10
+ <a href="https://pypi.org/project/ixt-cli/"><img src="https://img.shields.io/pypi/v/ixt-cli?label=pypi" alt="PyPI"></a>
11
+ <a href="https://pepy.tech/projects/ixt-cli"><img src="https://static.pepy.tech/badge/ixt-cli/month" alt="PyPI Downloads"></a>
10
12
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
11
13
  <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
12
14
  </p>
@@ -20,10 +22,10 @@
20
22
  *Zero third-party deps. Zero system pollution. One command, any ecosystem.*
21
23
 
22
24
  - **Day-one GitHub releases** — install a tool the day it ships, without
23
- waiting for a formula, registry entry, or plugin.
25
+ waiting for a formula, registry entry, or plugin.
24
26
  - **No system runtime needed** — ixt is pure Python stdlib; `uv` and `bun` are
25
- bootstrapped on first use, so the host does not need Node, npm, or a package
26
- manager pre-installed.
27
+ bootstrapped on first use, so the host does not need Node, npm, or a package
28
+ manager pre-installed.
27
29
 
28
30
  ```bash
29
31
  ixt tool install ruff # Python (PyPI)
@@ -155,34 +157,59 @@ ixt tool install sharkdp/fd@10 # latest 10.x.x
155
157
  ixt tool install ruff>=0.5.0 # PEP 508 (Python)
156
158
  ```
157
159
 
160
+ ## 🔒 Declarative locks
161
+
162
+ `ixt.toml` is the human-edited intent: which tools you want, with optional
163
+ version bounds, exposure rules, and injections. `ixt.lock` is generated from
164
+ that intent and records the exact resolved versions and binary release assets.
165
+
166
+ For teams and CI, commit both files:
167
+
168
+ ```bash
169
+ ixt lock write
170
+ ixt lock check
171
+ ixt tool apply --locked --strict
172
+ ```
173
+
174
+ `--locked` replays the exact lock instead of resolving newer versions.
175
+ `--strict` fails before changing the machine if `ixt.toml` and `ixt.lock`
176
+ diverge. The strict preflight is local: it checks the manifest fingerprint,
177
+ declared policy, version constraints, and locked binary metadata without
178
+ contacting PyPI, npm, GitHub, or GitLab. Run `ixt lock write` when you want to
179
+ resolve upstream again and refresh the lock.
180
+
158
181
  ## 🧰 Commands
159
182
 
160
183
  | Command | Description |
161
184
  | ---------------------------------------------- | --------------------------------------------------------------------- |
162
- | `ixt tool add <pkg>` | Add a tool to ixt.toml without installing |
163
- | `ixt tool install <pkg>` | Install a tool in isolation |
164
- | `ixt tool install <pkg> --bare` | Install the env without exposing any binary |
165
- | `ixt tool uninstall <pkg>` | Remove a tool |
166
- | `ixt tool list` | List installed tools |
167
- | `ixt tool info <pkg>` | Show tool details |
168
- | `ixt tool upgrade <pkg>` | Upgrade a tool |
169
- | `ixt tool upgrade --all` | Upgrade all tools |
170
- | `ixt tool apply` | Sync tools from declarative config |
171
- | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
185
+ | `ixt tool add <pkg>` | Add a tool to ixt.toml without installing |
186
+ | `ixt tool install <pkg>` | Install a tool in isolation |
187
+ | `ixt tool install <pkg> --bare` | Install the env without exposing any binary |
188
+ | `ixt tool uninstall <pkg>` | Remove a tool |
189
+ | `ixt tool list` | List installed tools |
190
+ | `ixt tool info <pkg>` | Show tool details |
191
+ | `ixt tool upgrade <pkg>` | Upgrade a tool |
192
+ | `ixt tool upgrade --all` | Upgrade all tools |
193
+ | `ixt tool apply` | Sync tools from declarative config |
194
+ | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
195
+ | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
196
+ | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
197
+ | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
198
+ | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
172
199
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
173
- | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
174
- | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
175
- | `ixt tool config <pkg> uninject <dep>` | Remove an injected package |
176
- | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
177
- | `ixt tool shell <pkg>` | Open a subshell in tool's env |
178
- | `ixt tool where <pkg>` | Print the tool env path |
200
+ | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
201
+ | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
202
+ | `ixt tool config <pkg> uninject <dep>` | Remove an injected package |
203
+ | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
204
+ | `ixt tool shell <pkg>` | Open a subshell in tool's env |
205
+ | `ixt tool where <pkg>` | Print the tool env path |
179
206
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
180
207
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
181
208
  | `ixt cache info` | Show cache paths and sizes |
182
209
  | `ixt cache prune` | Keep current + previous indexed download artifacts per repository |
183
210
  | `ixt cache clear {downloads,metadata,tmp,all}` | Wipe cache(s); never touches installed tools |
184
- | `ixt runtime info` | Show ixt-managed runtime state |
185
- | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
211
+ | `ixt runtime info` | Show ixt-managed runtime state |
212
+ | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
186
213
  | `ixt setup path` | Add `$IXT_HOME/installed/bin` to PATH |
187
214
  | `ixt setup completions --shell zsh` | Print shell completion code for ixt |
188
215
 
@@ -191,7 +218,8 @@ ixt tool install ruff>=0.5.0 # PEP 508 (Python)
191
218
  ```
192
219
  $IXT_HOME/ # default ~/.local/share/ixt (respects $XDG_DATA_HOME)
193
220
  ├── config/
194
- │ ├── ixt.toml
221
+ │ ├── ixt.toml # global declarative manifest fallback
222
+ │ ├── ixt.lock # global lock when using the global ixt.toml
195
223
  │ ├── registry.toml
196
224
  │ └── heuristics.toml
197
225
  └── installed/
@@ -231,17 +259,17 @@ ixt is **not** a runtime version manager (mise, proto, asdf cover that). It mana
231
259
  git clone https://gitlab.com/pytgaen-group/ixt.git && cd ixt
232
260
  uv sync --group dev
233
261
 
234
- uv run pytest # test suite
262
+ uv run pytest # hermetic unit suite
235
263
  uv run ruff check src/ tests/ # lint
236
264
  uv run ruff format src/ tests/ # format
237
265
  ```
238
266
 
239
267
  ## 🤝 Contributing
240
268
 
241
- Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. The GitHub repository (if present) is a **read-only mirror** — please open issues and merge requests on GitLab.
269
+ Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. Any GitHub repository for this project is a **read-only mirror** of GitLab; it is not the review or issue tracker. Please open issues and merge requests on GitLab.
242
270
 
243
271
  Bug reports and reproduction recipes are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details, or open an issue to start a discussion.
244
272
 
245
273
  ## 📄 License
246
274
 
247
- MIT
275
+ MIT. See [`LICENSE`](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ixt-cli"
7
- version = "0.8.1" # to bump
7
+ version = "0.9.1" # to bump
8
8
  description = "Universal CLI tool isolator - manage Python, Node, and binary tools with per-tool isolation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -37,10 +37,6 @@ dev = [
37
37
  "pytest-cov>=4.1",
38
38
  "pyright>=1.1",
39
39
  ]
40
- # Standalone binary build (Nuitka). Not a runtime dependency — install with
41
- # `uv sync --group build`. [onefile] pulls zstandard for payload compression.
42
- # See notes/standalone-build.md.
43
- build = ["nuitka[onefile]>=2.5"]
44
40
 
45
41
  [project.scripts]
46
42
  ixt = "ixt.cli:main"
@@ -65,14 +61,15 @@ include = [
65
61
  # Testing
66
62
  [tool.pytest.ini_options]
67
63
  minversion = "7.0"
68
- testpaths = ["tests"]
64
+ testpaths = ["tests/unit"]
69
65
  python_files = ["test_*.py"]
70
66
  python_classes = ["Test*"]
71
67
  python_functions = ["test_*"]
72
- addopts = "-ra --strict-markers"
68
+ addopts = '-ra --strict-markers -m "not slow and not network"'
73
69
  markers = [
74
70
  "unit: unit tests",
75
71
  "integration: integration tests",
72
+ "e2e: end-to-end tests against an installed ixt CLI",
76
73
  "platform: platform-specific tests (linux, macos, windows)",
77
74
  "slow: tests that bootstrap external runtimes (uv, bun) — skip by default with -m 'not slow'",
78
75
  "network: tests that hit the public internet (e.g. real GitHub releases) — opt in with -m 'network'",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import json
6
7
  import re
7
8
  from dataclasses import dataclass, field
@@ -18,22 +19,27 @@ from ixt.config.flags import is_setup_toml_enabled
18
19
  from ixt.config.heuristics import (
19
20
  get_name_suffix_tokens,
20
21
  get_platform_aliases,
22
+ is_asset_arch_compatible,
21
23
  load_heuristics,
22
24
  select_asset,
23
25
  )
24
26
 
25
27
  if TYPE_CHECKING:
28
+ from ixt.config.lockfile import BinaryLockMetadata
26
29
  from ixt.config.setup_toml import SetupConfig
27
30
  from ixt.config.settings import Settings, get_settings
28
31
  from ixt.core.backend import BackendType
29
32
  from ixt.core.discover import find_executables
30
33
  from ixt.core.extract import extract, is_archive
31
34
  from ixt.libs.logger import get_logger, get_verbosity
35
+ from ixt.net.http import MAX_DOWNLOAD_SIZE, DownloadTooLargeError
32
36
  from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
37
+ from ixt.platform import get_arch, get_os
33
38
 
34
39
  __all__ = [
35
40
  "AssetSelectionError",
36
41
  "BinaryBackend",
42
+ "BinaryPlatformError",
37
43
  "BinarySpecError",
38
44
  "HookEntry",
39
45
  "SetupMetadata",
@@ -42,6 +48,9 @@ __all__ = [
42
48
  "make_source",
43
49
  ]
44
50
 
51
+ BINARY_ASSET_CACHE_SCHEMA = 2
52
+ _CACHE_ARCHIVE_SUFFIXES = (".tar.bz2", ".tar.gz", ".tar.xz", ".tgz", ".txz", ".zip")
53
+
45
54
 
46
55
  # 3.10 lacks typing.NotRequired (added in 3.11) and the project bans
47
56
  # third-party deps (typing_extensions). The required-base + total=False
@@ -89,6 +98,31 @@ class AssetSelectionError(RuntimeError):
89
98
  )
90
99
 
91
100
 
101
+ class BinaryPlatformError(RuntimeError):
102
+ """Raised when extracted executables are incompatible with the current platform."""
103
+
104
+ def __init__(
105
+ self,
106
+ spec: str,
107
+ release_tag: str,
108
+ asset_name: str,
109
+ rejected: list[tuple[Path, str]],
110
+ ):
111
+ self.spec = spec
112
+ self.release_tag = release_tag
113
+ self.asset_name = asset_name
114
+ self.rejected = rejected
115
+ platform = f"{get_os().value}/{get_arch().value}"
116
+ details = ", ".join(
117
+ f"{path.name} ({format_label})"
118
+ for path, format_label in sorted(rejected, key=lambda item: str(item[0]))
119
+ )
120
+ super().__init__(
121
+ f"No executable compatible with {platform} found for '{spec}' "
122
+ f"(release {release_tag}, asset {asset_name}). Rejected: {details}"
123
+ )
124
+
125
+
92
126
  class BinarySpecError(ValueError):
93
127
  """Raised when a spec cannot be parsed as a binary backend spec."""
94
128
 
@@ -239,10 +273,51 @@ def _cache_dir_for(settings: Settings, repo_spec: RepoSpec) -> Path:
239
273
  return settings.cache_dir / host / f"{repo_spec.owner}-{repo_spec.repo}"
240
274
 
241
275
 
242
- def _cached_asset(cache_dir: Path, asset_name: str) -> Path | None:
243
- """Return the cached asset path if it exists."""
244
- path = cache_dir / asset_name
245
- if path.exists() and path.stat().st_size > 0:
276
+ def _asset_cache_key(repo_spec: RepoSpec, asset: Asset, *, tag: str) -> str:
277
+ """Return the stable cache identity for one exact release asset."""
278
+ identity = json.dumps(
279
+ {
280
+ "schema": BINARY_ASSET_CACHE_SCHEMA,
281
+ "platform": repo_spec.platform,
282
+ "host": repo_spec.host,
283
+ "owner": repo_spec.owner,
284
+ "repo": repo_spec.repo,
285
+ "tag": tag,
286
+ "asset": asset.name,
287
+ "url": asset.url,
288
+ },
289
+ sort_keys=True,
290
+ separators=(",", ":"),
291
+ )
292
+ return hashlib.sha256(identity.encode()).hexdigest()
293
+
294
+
295
+ def _asset_cache_path(
296
+ downloads_dir: Path,
297
+ repo_spec: RepoSpec,
298
+ asset: Asset,
299
+ *,
300
+ tag: str,
301
+ ) -> Path:
302
+ """Return a portable path for one exact release asset."""
303
+ lower_name = asset.name.lower()
304
+ suffix = next((item for item in _CACHE_ARCHIVE_SUFFIXES if lower_name.endswith(item)), "")
305
+ if not suffix:
306
+ match = re.search(r"\.[A-Za-z0-9]{1,10}$", asset.name)
307
+ suffix = match.group(0) if match is not None else ""
308
+ key = _asset_cache_key(repo_spec, asset, tag=tag)
309
+ return downloads_dir / f"assets-v{BINARY_ASSET_CACHE_SCHEMA}" / key / f"asset{suffix}"
310
+
311
+
312
+ def _cached_asset(path: Path, expected_size: int = 0) -> Path | None:
313
+ """Return *path* when it is a non-empty cache entry of the expected size."""
314
+ try:
315
+ if not path.is_file():
316
+ return None
317
+ size = path.stat().st_size
318
+ except OSError:
319
+ return None
320
+ if 0 < size <= MAX_DOWNLOAD_SIZE and (expected_size <= 0 or size == expected_size):
246
321
  return path
247
322
  return None
248
323
 
@@ -396,6 +471,7 @@ class BinaryBackend:
396
471
  setup_path: Path | None = None,
397
472
  asset_pattern: str | None = None,
398
473
  known_tag: str | None = None,
474
+ locked_binary: BinaryLockMetadata | None = None,
399
475
  use_cache: bool = True,
400
476
  ) -> dict | None:
401
477
  """Compatibility adapter for the shared backend protocol."""
@@ -410,6 +486,7 @@ class BinaryBackend:
410
486
  setup_path=setup_path,
411
487
  asset_pattern=asset_pattern,
412
488
  known_tag=known_tag,
489
+ locked_binary=locked_binary,
413
490
  use_cache=use_cache,
414
491
  )
415
492
 
@@ -422,6 +499,7 @@ class BinaryBackend:
422
499
  setup_path: Path | None = None,
423
500
  asset_pattern: str | None = None,
424
501
  known_tag: str | None = None,
502
+ locked_binary: BinaryLockMetadata | None = None,
425
503
  use_cache: bool = True,
426
504
  ) -> dict | None:
427
505
  """Resolve, download, extract, and discover one atomic binary tool.
@@ -460,24 +538,34 @@ class BinaryBackend:
460
538
  )
461
539
  release_pattern_name = setup.name if setup and setup.name else None
462
540
 
463
- # Reuse the release already fetched during version resolution (e.g. an
464
- # upgrade or a dry-run within the TTL window). This skips a second API
465
- # call — the only option for GitLab, whose asset URLs can't be rebuilt
466
- # from a pattern like GitHub's.
467
- release = self._cached_release(spec) if use_cache and not repo_spec.version else None
468
- if release is not None:
469
- log.debug(f"release (resolve cache): {release.tag} ({len(release.assets)} assets)")
470
- else:
471
- release = self._fetch_release(
472
- source,
473
- repo_spec,
474
- env_dir=env_dir,
475
- upgrade=upgrade,
476
- known_tag=known_tag,
477
- asset_pattern=release_pattern,
478
- pattern_name=release_pattern_name,
541
+ if locked_binary is not None:
542
+ if not repo_spec.version:
543
+ raise BinarySpecError("locked binary install requires a pinned release tag")
544
+ release = Release(
545
+ tag=repo_spec.version,
546
+ assets=[Asset(name=locked_binary.asset, url=locked_binary.url)],
547
+ resolution_source="lockfile",
479
548
  )
480
- log.debug(f"release: {release.tag} ({len(release.assets)} assets)")
549
+ log.debug(f"release (lockfile): {release.tag} ({locked_binary.asset})")
550
+ else:
551
+ # Reuse the release already fetched during version resolution (e.g. an
552
+ # upgrade or a dry-run within the TTL window). This skips a second API
553
+ # call — the only option for GitLab, whose asset URLs can't be rebuilt
554
+ # from a pattern like GitHub's.
555
+ release = self._cached_release(spec) if use_cache and not repo_spec.version else None
556
+ if release is not None:
557
+ log.debug(f"release (resolve cache): {release.tag} ({len(release.assets)} assets)")
558
+ else:
559
+ release = self._fetch_release(
560
+ source,
561
+ repo_spec,
562
+ env_dir=env_dir,
563
+ upgrade=upgrade,
564
+ known_tag=known_tag,
565
+ asset_pattern=release_pattern,
566
+ pattern_name=release_pattern_name,
567
+ )
568
+ log.debug(f"release: {release.tag} ({len(release.assets)} assets)")
481
569
 
482
570
  if setup is None and not setup_resolved:
483
571
  setup = self._resolve_setup(
@@ -491,7 +579,16 @@ class BinaryBackend:
491
579
  if asset_pattern:
492
580
  log.debug(f"user-forced asset_pattern: {asset_pattern!r}")
493
581
  selection_pattern = _selection_pattern(effective_pattern, release.tag)
494
- asset = self._select_asset(release, selection_pattern, tool_name, spec)
582
+ if locked_binary is None:
583
+ asset = self._select_asset(release, selection_pattern, tool_name, spec)
584
+ else:
585
+ asset = release.assets[0]
586
+ if not is_asset_arch_compatible(
587
+ asset.name,
588
+ heuristics=load_heuristics(),
589
+ declared_platform=locked_binary.platform,
590
+ ):
591
+ raise AssetSelectionError(spec, release.tag, [asset.name])
495
592
  log.debug(f"selected asset: {asset.name}")
496
593
 
497
594
  archive_path = self._download_asset(
@@ -512,8 +609,16 @@ class BinaryBackend:
512
609
  log.debug(f"extracted to: {result.root}")
513
610
 
514
611
  heuristics = load_heuristics()
515
- executables = find_executables(result.root, expose=heuristics.expose, log=log)
612
+ rejected_executables: list[tuple[Path, str]] = []
613
+ executables = find_executables(
614
+ result.root,
615
+ expose=heuristics.expose,
616
+ log=log,
617
+ rejected=rejected_executables,
618
+ )
516
619
  log.debug(f"executables: {[e.name for e in executables]}")
620
+ if not executables and rejected_executables:
621
+ raise BinaryPlatformError(spec, release.tag, asset.name, rejected_executables)
517
622
 
518
623
  binaries = {exe.name: str(exe) for exe in executables}
519
624
 
@@ -898,15 +1003,28 @@ class BinaryBackend:
898
1003
  upgrade: bool = False,
899
1004
  ) -> Path:
900
1005
  """Download an asset, using cache when available."""
901
- cached = _cached_asset(cache_dir, asset.name)
1006
+ if asset.size > MAX_DOWNLOAD_SIZE:
1007
+ raise DownloadTooLargeError(asset.url, MAX_DOWNLOAD_SIZE, asset.size)
1008
+
1009
+ cache_key = _asset_cache_key(repo_spec, asset, tag=tag)
1010
+ dest = _asset_cache_path(self.settings.downloads_dir, repo_spec, asset, tag=tag)
1011
+ cached = _cached_asset(dest, asset.size)
902
1012
  if cached is not None and not upgrade:
903
- self._record_download(repo_spec, asset, cached, tag=tag)
1013
+ self._record_download(repo_spec, asset, cached, tag=tag, cache_key=cache_key)
904
1014
  return cached
905
1015
 
906
- cache_dir.mkdir(parents=True, exist_ok=True)
907
- dest = cache_dir / asset.name
1016
+ dest.parent.mkdir(parents=True, exist_ok=True)
1017
+ dest.unlink(missing_ok=True)
908
1018
  source.download_asset(asset.url, dest)
909
- self._record_download(repo_spec, asset, dest, tag=tag)
1019
+ if dest.is_file() and dest.stat().st_size > MAX_DOWNLOAD_SIZE:
1020
+ actual_size = dest.stat().st_size
1021
+ dest.unlink(missing_ok=True)
1022
+ raise DownloadTooLargeError(asset.url, MAX_DOWNLOAD_SIZE, actual_size)
1023
+ if _cached_asset(dest, asset.size) is None:
1024
+ dest.unlink(missing_ok=True)
1025
+ expected = f"{asset.size} bytes" if asset.size > 0 else "a non-empty file"
1026
+ raise RuntimeError(f"Downloaded asset {asset.name!r} does not match {expected}")
1027
+ self._record_download(repo_spec, asset, dest, tag=tag, cache_key=cache_key)
910
1028
  return dest
911
1029
 
912
1030
  def _record_download(
@@ -916,6 +1034,7 @@ class BinaryBackend:
916
1034
  path: Path,
917
1035
  *,
918
1036
  tag: str,
1037
+ cache_key: str,
919
1038
  ) -> None:
920
1039
  """Index the downloaded binary asset for later cache pruning."""
921
1040
  from ixt.core.cache import record_download
@@ -932,4 +1051,5 @@ class BinaryBackend:
932
1051
  asset_url=asset.url,
933
1052
  asset_size=asset.size,
934
1053
  path=path,
1054
+ cache_key=cache_key,
935
1055
  )