ixt-cli 0.9.1__tar.gz → 0.10.0__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 (161) hide show
  1. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/PKG-INFO +3 -2
  2. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/README.md +2 -1
  3. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/pyproject.toml +1 -1
  4. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/backends/binary.py +136 -18
  5. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/backends/node.py +49 -14
  6. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/__init__.py +9 -0
  7. ixt_cli-0.10.0/src/ixt/cli/cmd_exec.py +71 -0
  8. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_info.py +5 -1
  9. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_misc.py +24 -1
  10. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/commands.py +3 -0
  11. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/parser.py +37 -0
  12. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/discover.py +27 -4
  13. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/doctor.py +56 -0
  14. ixt_cli-0.10.0/src/ixt/core/exec_tool.py +165 -0
  15. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/upgrade.py +19 -8
  16. ixt_cli-0.10.0/src/ixt/libs/elf.py +174 -0
  17. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/net/http.py +38 -0
  18. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_argparse_completion.py +1 -1
  19. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_binary_backend.py +256 -0
  20. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_cli.py +90 -0
  21. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_discover.py +63 -0
  22. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_doctor.py +94 -0
  23. ixt_cli-0.10.0/tests/unit/test_elf.py +230 -0
  24. ixt_cli-0.10.0/tests/unit/test_exec.py +289 -0
  25. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_http.py +49 -0
  26. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_node_backend.py +111 -9
  27. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_upgrade.py +74 -28
  28. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/.gitignore +0 -0
  29. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/LICENSE +0 -0
  30. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/__init__.py +0 -0
  31. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/__main__.py +0 -0
  32. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/backends/__init__.py +0 -0
  33. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/backends/binary_resolver.py +0 -0
  34. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/backends/python.py +0 -0
  35. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/argparse_completion.py +0 -0
  36. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_apply.py +0 -0
  37. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_cache.py +0 -0
  38. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_config.py +0 -0
  39. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_install.py +0 -0
  40. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_lock.py +0 -0
  41. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_registry.py +0 -0
  42. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/cmd_upgrade.py +0 -0
  43. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/cli/render.py +0 -0
  44. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/__init__.py +0 -0
  45. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/asset_index.py +0 -0
  46. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/asset_pattern_cache.py +0 -0
  47. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/env_policy.py +0 -0
  48. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/flags.py +0 -0
  49. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/fs_policy.py +0 -0
  50. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/heuristics.py +0 -0
  51. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/lockfile.py +0 -0
  52. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/models.py +0 -0
  53. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/registry.py +0 -0
  54. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/settings.py +0 -0
  55. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/setup_toml.py +0 -0
  56. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/config/toml.py +0 -0
  57. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/__init__.py +0 -0
  58. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/apply.py +0 -0
  59. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/apply_actions.py +0 -0
  60. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/backend.py +0 -0
  61. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/bootstrap.py +0 -0
  62. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/cache.py +0 -0
  63. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/export.py +0 -0
  64. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/expose.py +0 -0
  65. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/extract.py +0 -0
  66. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/hooks.py +0 -0
  67. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/identity.py +0 -0
  68. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/inject.py +0 -0
  69. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/install.py +0 -0
  70. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/install_local.py +0 -0
  71. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/lockfile.py +0 -0
  72. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/locks.py +0 -0
  73. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/pathlink.py +0 -0
  74. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/resolution_stats.py +0 -0
  75. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/resolve.py +0 -0
  76. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/resolve_cache.py +0 -0
  77. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/runtimes.py +0 -0
  78. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/save.py +0 -0
  79. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/setup_completions.py +0 -0
  80. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/setup_path.py +0 -0
  81. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/core/transactions.py +0 -0
  82. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/data/__init__.py +0 -0
  83. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/data/asset_index.json +0 -0
  84. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/data/heuristics.toml +0 -0
  85. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/data/registry.toml +0 -0
  86. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/__init__.py +0 -0
  87. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/atomic.py +0 -0
  88. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/constants.py +0 -0
  89. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/fmt.py +0 -0
  90. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/logger.py +0 -0
  91. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/output.py +0 -0
  92. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/req_spec.py +0 -0
  93. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/semver.py +0 -0
  94. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/shell.py +0 -0
  95. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/libs/style.py +0 -0
  96. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/net/__init__.py +0 -0
  97. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/net/github_api.py +0 -0
  98. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/net/gitlab_api.py +0 -0
  99. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/net/npm.py +0 -0
  100. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/net/pypi.py +0 -0
  101. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/net/source.py +0 -0
  102. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/platform/__init__.py +0 -0
  103. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/src/ixt/platform/win.py +0 -0
  104. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/__init__.py +0 -0
  105. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/conftest.py +0 -0
  106. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/README.md +0 -0
  107. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/conftest.py +0 -0
  108. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/docker/Dockerfile.alpine +0 -0
  109. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/docker/Dockerfile.fedora +0 -0
  110. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/docker/Dockerfile.ubuntu +0 -0
  111. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/playground_incus.sh +0 -0
  112. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/run_e2e_docker.sh +0 -0
  113. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/run_e2e_incus.sh +0 -0
  114. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/runtime_matrix.sh +0 -0
  115. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/e2e/test_e2e.py +0 -0
  116. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/integration/__init__.py +0 -0
  117. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/integration/test_lifecycle.py +0 -0
  118. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/integration/test_policy_runtime.py +0 -0
  119. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/__init__.py +0 -0
  120. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_apply.py +0 -0
  121. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_asset_index.py +0 -0
  122. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_asset_pattern_cache.py +0 -0
  123. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_atomic.py +0 -0
  124. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_backend.py +0 -0
  125. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_bootstrap.py +0 -0
  126. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_cache.py +0 -0
  127. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_env_policy.py +0 -0
  128. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_export.py +0 -0
  129. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_expose.py +0 -0
  130. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_extract.py +0 -0
  131. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_flags.py +0 -0
  132. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_fs_policy.py +0 -0
  133. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_github_api.py +0 -0
  134. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_gitlab_api.py +0 -0
  135. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_heuristics.py +0 -0
  136. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_hooks.py +0 -0
  137. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_inject.py +0 -0
  138. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_install.py +0 -0
  139. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_install_local.py +0 -0
  140. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_lock_write.py +0 -0
  141. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_lockfile.py +0 -0
  142. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_models.py +0 -0
  143. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_namespacing.py +0 -0
  144. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_output.py +0 -0
  145. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_pathlink.py +0 -0
  146. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_platform.py +0 -0
  147. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_python_backend.py +0 -0
  148. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_registry.py +0 -0
  149. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_req_spec.py +0 -0
  150. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_resolve.py +0 -0
  151. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_resolve_cache.py +0 -0
  152. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_runtimes.py +0 -0
  153. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_save.py +0 -0
  154. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_semver.py +0 -0
  155. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_settings.py +0 -0
  156. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_setup_path.py +0 -0
  157. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_setup_toml.py +0 -0
  158. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_source.py +0 -0
  159. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_style.py +0 -0
  160. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_toml.py +0 -0
  161. {ixt_cli-0.9.1 → ixt_cli-0.10.0}/tests/unit/test_transactions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ixt-cli
3
- Version: 0.9.1
3
+ Version: 0.10.0
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
@@ -223,7 +223,7 @@ resolve upstream again and refresh the lock.
223
223
  | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
224
224
  | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
225
225
  | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
226
- | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
226
+ | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
227
227
  | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
228
228
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
229
229
  | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
@@ -232,6 +232,7 @@ resolve upstream again and refresh the lock.
232
232
  | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
233
233
  | `ixt tool shell <pkg>` | Open a subshell in tool's env |
234
234
  | `ixt tool where <pkg>` | Print the tool env path |
235
+ | `ixt exec [--sudo] <tool> -- <args...>` | Run an exposed tool by name, without relying on PATH |
235
236
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
236
237
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
237
238
  | `ixt cache info` | Show cache paths and sizes |
@@ -194,7 +194,7 @@ resolve upstream again and refresh the lock.
194
194
  | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
195
195
  | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
196
196
  | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
197
- | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
197
+ | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
198
198
  | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
199
199
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
200
200
  | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
@@ -203,6 +203,7 @@ resolve upstream again and refresh the lock.
203
203
  | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
204
204
  | `ixt tool shell <pkg>` | Open a subshell in tool's env |
205
205
  | `ixt tool where <pkg>` | Print the tool env path |
206
+ | `ixt exec [--sudo] <tool> -- <args...>` | Run an exposed tool by name, without relying on PATH |
206
207
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
207
208
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
208
209
  | `ixt cache info` | Show cache paths and sizes |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ixt-cli"
7
- version = "0.9.1" # to bump
7
+ version = "0.10.0" # 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"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import json
7
+ import os
7
8
  import re
8
9
  from dataclasses import dataclass, field
9
10
  from pathlib import Path
@@ -31,8 +32,10 @@ from ixt.config.settings import Settings, get_settings
31
32
  from ixt.core.backend import BackendType
32
33
  from ixt.core.discover import find_executables
33
34
  from ixt.core.extract import extract, is_archive
35
+ from ixt.libs.elf import PREFIX_SIZE as ELF_PREFIX_SIZE
36
+ from ixt.libs.elf import parse_elf, read_elf
34
37
  from ixt.libs.logger import get_logger, get_verbosity
35
- from ixt.net.http import MAX_DOWNLOAD_SIZE, DownloadTooLargeError
38
+ from ixt.net.http import MAX_DOWNLOAD_SIZE, DownloadTooLargeError, get_range
36
39
  from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
37
40
  from ixt.platform import get_arch, get_os
38
41
 
@@ -88,14 +91,27 @@ class SetupMetadata(_SetupMetadataRequired, total=False):
88
91
  class AssetSelectionError(RuntimeError):
89
92
  """Raised when no suitable asset could be selected for the current platform."""
90
93
 
91
- def __init__(self, spec: str, release_tag: str, asset_names: list[str]):
94
+ def __init__(
95
+ self,
96
+ spec: str,
97
+ release_tag: str,
98
+ asset_names: list[str],
99
+ unusable: list[tuple[str, str]] | None = None,
100
+ ):
92
101
  self.spec = spec
93
102
  self.release_tag = release_tag
94
103
  self.asset_names = asset_names
104
+ self.unusable = unusable or []
95
105
  names = ", ".join(asset_names[:10]) or "<none>"
96
- super().__init__(
106
+ message = (
97
107
  f"No suitable asset found for '{spec}' (release {release_tag}). Available: {names}"
98
108
  )
109
+ if self.unusable:
110
+ skipped = ", ".join(
111
+ f"{name} (needs missing loader {loader})" for name, loader in self.unusable
112
+ )
113
+ message += f". Skipped as unrunnable here: {skipped}"
114
+ super().__init__(message)
99
115
 
100
116
 
101
117
  class BinaryPlatformError(RuntimeError):
@@ -580,7 +596,22 @@ class BinaryBackend:
580
596
  log.debug(f"user-forced asset_pattern: {asset_pattern!r}")
581
597
  selection_pattern = _selection_pattern(effective_pattern, release.tag)
582
598
  if locked_binary is None:
583
- asset = self._select_asset(release, selection_pattern, tool_name, spec)
599
+ try:
600
+ asset = self._select_asset(
601
+ release, selection_pattern, tool_name, spec, repo_spec=repo_spec
602
+ )
603
+ except AssetSelectionError:
604
+ # A synthesized release carries only the asset its pattern named.
605
+ # If that asset cannot run here, the pattern is wrong for this
606
+ # host, not merely stale — relearn it against the full release.
607
+ relearned = self._relearn_release(source, repo_spec, release)
608
+ if relearned is None:
609
+ raise
610
+ release = relearned
611
+ selection_pattern = _selection_pattern(effective_pattern, release.tag)
612
+ asset = self._select_asset(
613
+ release, selection_pattern, tool_name, spec, repo_spec=repo_spec
614
+ )
584
615
  else:
585
616
  asset = release.assets[0]
586
617
  if not is_asset_arch_compatible(
@@ -733,23 +764,110 @@ class BinaryBackend:
733
764
  asset_pattern: str | None,
734
765
  tool_name: str,
735
766
  main_spec: str,
767
+ *,
768
+ repo_spec: RepoSpec | None = None,
736
769
  ) -> Asset:
770
+ log = get_logger("binary")
737
771
  heuristics = load_heuristics()
738
- asset = select_asset(
739
- release.assets,
740
- tool_name,
741
- _strip_v_prefix(release.tag),
742
- heuristics=heuristics,
743
- asset_pattern=asset_pattern,
744
- verbose=get_verbosity(),
745
- )
746
- if asset is None:
747
- raise AssetSelectionError(
748
- main_spec,
749
- release.tag,
750
- [a.name for a in release.assets],
772
+ candidates = list(release.assets)
773
+ unusable: list[tuple[str, str]] = []
774
+
775
+ while candidates:
776
+ asset = select_asset(
777
+ candidates,
778
+ tool_name,
779
+ _strip_v_prefix(release.tag),
780
+ heuristics=heuristics,
781
+ asset_pattern=asset_pattern,
782
+ verbose=get_verbosity(),
751
783
  )
752
- return asset
784
+ if asset is None:
785
+ break
786
+
787
+ loader = self._unrunnable_loader(asset, repo_spec=repo_spec, tag=release.tag)
788
+ if loader is None:
789
+ return asset
790
+
791
+ log.debug(f"asset skip: {asset.name} needs missing loader {loader}")
792
+ unusable.append((asset.name, loader))
793
+ candidates = [a for a in candidates if a.name != asset.name]
794
+
795
+ raise AssetSelectionError(
796
+ main_spec,
797
+ release.tag,
798
+ [a.name for a in release.assets],
799
+ unusable,
800
+ )
801
+
802
+ def _relearn_release(
803
+ self,
804
+ source: ReleaseSource,
805
+ repo_spec: RepoSpec,
806
+ release: Release,
807
+ ) -> Release | None:
808
+ """Re-fetch *release* from the API after its learned pattern proved wrong.
809
+
810
+ The fast path synthesizes a release holding only the asset its pattern
811
+ names, so a pattern pointing at an unrunnable build leaves selection
812
+ with nothing to fall back on. The pattern is dropped and the real asset
813
+ list fetched, which lets scoring reach the runnable siblings.
814
+
815
+ Returns None when the release already came from the API: there is then
816
+ no wider list to fall back to, and the caller should surface the
817
+ original selection error instead.
818
+ """
819
+ if (release.resolution_source or "").endswith("-api"):
820
+ return None
821
+
822
+ log = get_logger("binary")
823
+ log.debug(
824
+ f"asset pattern for {repo_spec.owner}/{repo_spec.repo} names an unrunnable "
825
+ "asset — invalidating and refetching from the release API"
826
+ )
827
+ from ixt.config import asset_pattern_cache
828
+
829
+ asset_pattern_cache.invalidate(repo_spec.owner, repo_spec.repo)
830
+ self._require_github_api(repo_spec)
831
+ refetched = source.get_release_by_tag(repo_spec.owner, repo_spec.repo, release.tag)
832
+ refetched.resolution_source = f"{repo_spec.platform}-api"
833
+ log.debug(f"release (relearn): {refetched.tag} ({len(refetched.assets)} assets)")
834
+ return refetched
835
+
836
+ def _unrunnable_loader(
837
+ self,
838
+ asset: Asset,
839
+ *,
840
+ repo_spec: RepoSpec | None,
841
+ tag: str,
842
+ ) -> str | None:
843
+ """Return the loader *asset* needs but this host lacks, else None.
844
+
845
+ A cached asset is read from disk; otherwise only the head of the asset
846
+ is fetched over HTTP Range, which keeps a doomed candidate from costing
847
+ a full download. Only bare binaries can be checked this way — an
848
+ archive reveals nothing at offset zero, so those are validated after
849
+ extraction by ``find_executables`` instead.
850
+
851
+ Any doubt resolves to None: a server without Range support, a network
852
+ hiccup, or a truncated prefix all fall through to the normal path.
853
+ """
854
+ if os.name == "nt" or is_archive(asset.name):
855
+ return None
856
+ if repo_spec is not None:
857
+ cache_path = _asset_cache_path(self.settings.downloads_dir, repo_spec, asset, tag=tag)
858
+ cached = _cached_asset(cache_path, asset.size)
859
+ if cached is not None:
860
+ # Already on disk: judge the real file, no request needed.
861
+ info = read_elf(cached)
862
+ return info.interpreter if info is not None and info.loader_missing() else None
863
+
864
+ head = get_range(asset.url, ELF_PREFIX_SIZE)
865
+ if head is None:
866
+ return None
867
+ info = parse_elf(head)
868
+ if info is None or not info.loader_missing():
869
+ return None
870
+ return info.interpreter
753
871
 
754
872
  def uninstall_package(self, env_dir: Path, package: str) -> None:
755
873
  """No-op — binary tools are atomic (uninstall removes the whole env)."""
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import json
11
11
  import os
12
12
  import re
13
+ import shlex
13
14
  import shutil
14
15
  from dataclasses import dataclass, field
15
16
  from pathlib import Path
@@ -27,6 +28,29 @@ from ixt.platform import is_windows
27
28
  # #!/usr/bin/env nodemon → no match
28
29
  _NODE_SHEBANG_RE = re.compile(rb"^#!.*\bnode\b")
29
30
 
31
+
32
+ def _resolve_bun_path(bun_path: str) -> str:
33
+ """Return an absolute path without resolving a stable bun symlink."""
34
+ candidate = Path(bun_path).expanduser()
35
+ if not candidate.is_absolute():
36
+ resolved = shutil.which(bun_path)
37
+ if resolved is None:
38
+ raise FileNotFoundError(f"Cannot resolve bun executable: {bun_path}")
39
+ candidate = Path(resolved)
40
+ return str(candidate.absolute())
41
+
42
+
43
+ def _bun_shebang(bun_path: str) -> bytes:
44
+ """Build a portable launcher header for an absolute bun path."""
45
+ if "\n" in bun_path or "\r" in bun_path:
46
+ raise ValueError("bun executable path cannot contain a line break")
47
+ if any(char.isspace() for char in bun_path):
48
+ # macOS splits shebang arguments before ``env -S`` can parse quotes.
49
+ # This second line is a shell exec and a JavaScript no-op/comment.
50
+ return (f'#!/bin/sh\n\':\' //; exec {shlex.quote(bun_path)} "$0" "$@"').encode()
51
+ return f"#!{bun_path}".encode()
52
+
53
+
30
54
  # ---------------------------------------------------------------------------
31
55
  # npm spec helpers
32
56
  # ---------------------------------------------------------------------------
@@ -190,9 +214,11 @@ def _shim_node_shebangs(env_dir: Path, bun_path: str) -> list[str]:
190
214
  Returns the list of ``.bin/`` entry names whose real script was
191
215
  rewritten (for a user-facing warning).
192
216
  """
193
- if shutil.which("node"):
217
+ if is_windows() or shutil.which("node"):
194
218
  return []
195
219
 
220
+ bun_path = _resolve_bun_path(bun_path)
221
+
196
222
  rewritten: list[str] = []
197
223
  bin_dir = _dot_bin_dir(env_dir)
198
224
  if not bin_dir.exists():
@@ -226,7 +252,7 @@ def _shim_node_shebangs(env_dir: Path, bun_path: str) -> list[str]:
226
252
 
227
253
  # Break potential hardlink to bun's global cache, then rewrite.
228
254
  mode = target.stat().st_mode
229
- new_content = f"#!{bun_path}".encode() + content[nl:]
255
+ new_content = _bun_shebang(bun_path) + content[nl:]
230
256
  target.unlink()
231
257
  target.write_bytes(new_content)
232
258
  target.chmod(mode)
@@ -321,10 +347,19 @@ class NodeBackend:
321
347
  main_spec = specs[0]
322
348
  pkg_name, _version = parse_npm_spec(main_spec)
323
349
 
324
- used_npm = self._run_install_command(env_dir, specs, runtime=runtime, upgrade=upgrade)
350
+ used_npm, bun_path = self._run_install_command(
351
+ env_dir, specs, runtime=runtime, upgrade=upgrade
352
+ )
325
353
  pkg_data, used_npm = self._verify_and_retry_with_npm(env_dir, pkg_name, specs, used_npm)
354
+ if used_npm:
355
+ bun_path = None
326
356
 
327
- self._maybe_shim_node_shebangs(env_dir, used_npm=used_npm, node_shim=node_shim)
357
+ self._maybe_shim_node_shebangs(
358
+ env_dir,
359
+ used_npm=used_npm,
360
+ node_shim=node_shim,
361
+ bun_path=bun_path,
362
+ )
328
363
 
329
364
  binaries = {p.stem: str(p) for p in _list_dot_bin(env_dir)}
330
365
 
@@ -345,10 +380,10 @@ class NodeBackend:
345
380
  *,
346
381
  runtime: str | None,
347
382
  upgrade: bool,
348
- ) -> bool:
383
+ ) -> tuple[bool, str | None]:
349
384
  """Run the install via bun (default) or npm (explicit --runtime=node).
350
385
 
351
- Returns True when npm was used (directly or as a bun-failure fallback).
386
+ Return whether npm was used and the exact bun path used otherwise.
352
387
  """
353
388
  log = get_logger("node")
354
389
 
@@ -361,14 +396,14 @@ class NodeBackend:
361
396
  log.debug(f"npm install (--runtime=node): {' '.join(specs)}")
362
397
  cmd = ["npm", "install", "--no-fund", "--no-audit", *specs]
363
398
  shell_run(cmd, cwd=env_dir, capture_output=True, check=True)
364
- return True
399
+ return True, None
365
400
 
366
401
  try:
367
- bun = self._bun()
402
+ bun = _resolve_bun_path(self._bun())
368
403
  cmd = [bun, "add", *(["--force"] if upgrade else []), *specs]
369
404
  log.debug(f"bun add: {' '.join(specs)}")
370
405
  shell_run(cmd, cwd=env_dir, capture_output=True, check=True)
371
- return False
406
+ return False, bun
372
407
  except (ShellError, Exception) as exc:
373
408
  if not self._npm_available():
374
409
  raise RuntimeError(
@@ -379,7 +414,7 @@ class NodeBackend:
379
414
  log.debug(f"bun failed, falling back to npm: {exc}")
380
415
  cmd = ["npm", "install", "--no-fund", "--no-audit", *specs]
381
416
  shell_run(cmd, cwd=env_dir, capture_output=True, check=True)
382
- return True
417
+ return True, None
383
418
 
384
419
  def _verify_and_retry_with_npm(
385
420
  self,
@@ -403,18 +438,18 @@ class NodeBackend:
403
438
  *,
404
439
  used_npm: bool,
405
440
  node_shim: bool | None,
441
+ bun_path: str | None,
406
442
  ) -> None:
407
443
  """Rewrite #!node shebangs to bun when bun installed and host has no node.
408
444
 
409
445
  Keeps the tool self-contained without requiring node on the host.
410
446
  """
411
- if used_npm or node_shim is False:
447
+ if used_npm or node_shim is False or bun_path is None:
412
448
  return
413
449
  log = get_logger("node")
414
450
  try:
415
- bun_for_shim = self._bun()
416
- rewritten = _shim_node_shebangs(env_dir, bun_for_shim)
417
- except (OSError, ShellError) as exc:
451
+ rewritten = _shim_node_shebangs(env_dir, bun_path)
452
+ except (OSError, ShellError, ValueError) as exc:
418
453
  log.debug(f"node shebang shim skipped: {exc}")
419
454
  return
420
455
  if rewritten:
@@ -7,9 +7,18 @@ __all__ = ["main"]
7
7
 
8
8
  def main() -> int:
9
9
  """Entry point for ixt CLI."""
10
+ import sys
11
+
10
12
  from ixt.cli.commands import COMMANDS
11
13
  from ixt.cli.parser import build_parser, normalize_verbosity
12
14
 
15
+ # 'exec' forwards every argument after the tool name to that tool, including
16
+ # '--help' and '--'. argparse cannot express that, so it never sees them.
17
+ if sys.argv[1:2] == ["exec"]:
18
+ from ixt.cli.cmd_exec import run_exec_argv
19
+
20
+ return run_exec_argv(sys.argv[2:])
21
+
13
22
  parser = build_parser()
14
23
  args = parser.parse_args()
15
24
 
@@ -0,0 +1,71 @@
1
+ """``ixt exec`` — run a managed tool by its exposed name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ixt.libs.logger import get_logger
10
+ from ixt.libs.style import dim
11
+
12
+ if TYPE_CHECKING:
13
+ from ixt.core.exec_tool import ExecError, ExecRequest
14
+
15
+
16
+ def _report(exc: ExecError) -> int:
17
+ logger = get_logger("exec")
18
+ logger.error(str(exc))
19
+ if exc.hint:
20
+ logger.error(f" {dim('Hint: ' + exc.hint)}")
21
+ return 1
22
+
23
+
24
+ def _run(request: ExecRequest) -> int:
25
+ from ixt.config.settings import get_settings
26
+ from ixt.core.exec_tool import ExecError, build_exec_plan, run_exec_plan
27
+
28
+ try:
29
+ plan = build_exec_plan(request, get_settings().bin_dir)
30
+ if request.dry_run:
31
+ sys.stdout.write(f"{plan.render()}\n")
32
+ return 0
33
+ return run_exec_plan(plan)
34
+ except ExecError as exc:
35
+ return _report(exc)
36
+
37
+
38
+ def run_exec_argv(argv: list[str]) -> int:
39
+ """Entry point for the short-circuited ``ixt exec`` path.
40
+
41
+ Called from ``cli.main`` before argparse runs, so that options meant for
42
+ the tool — ``--help`` in particular — are never claimed by ixt.
43
+ """
44
+ from ixt.core.exec_tool import ExecError, ExecHelpRequested, parse_exec_argv
45
+
46
+ try:
47
+ request = parse_exec_argv(argv)
48
+ except ExecHelpRequested:
49
+ from ixt.cli.parser import build_parser
50
+
51
+ # argparse prints the subparser help and exits, as for any other command.
52
+ build_parser().parse_args(["exec", "--help"])
53
+ return 0
54
+ except ExecError as exc:
55
+ return _report(exc)
56
+
57
+ return _run(request)
58
+
59
+
60
+ def cmd_exec(args: argparse.Namespace) -> int:
61
+ """Dispatch entry point, kept so the declared subparser stays reachable."""
62
+ from ixt.core.exec_tool import ExecRequest
63
+
64
+ return _run(
65
+ ExecRequest(
66
+ tool=args.tool,
67
+ tool_args=tuple(getattr(args, "args", ())),
68
+ sudo=getattr(args, "sudo", False),
69
+ dry_run=getattr(args, "dry_run", False),
70
+ )
71
+ )
@@ -9,9 +9,10 @@ from ixt.cli.render import format_exposed_bin
9
9
  from ixt.config.models import ToolRecord
10
10
  from ixt.config.settings import get_settings
11
11
  from ixt.core.install import display_tool_name, resolve_tool_arg
12
+ from ixt.libs.elf import missing_loader
12
13
  from ixt.libs.logger import get_logger
13
14
  from ixt.libs.output import data
14
- from ixt.libs.style import bold, cyan, dim
15
+ from ixt.libs.style import bold, cyan, dim, yellow
15
16
 
16
17
 
17
18
  def _load_binary_metadata(env_dir: Path) -> dict | None:
@@ -110,6 +111,9 @@ def cmd_info(args: argparse.Namespace) -> int:
110
111
  data(" Exposed:")
111
112
  for name, source in sorted(record.exposed_bins.items()):
112
113
  data(f" {cyan(name)} {dim('->')} {dim(source)}")
114
+ loader = missing_loader(Path(source))
115
+ if loader:
116
+ data(f" {yellow(f'! not runnable: missing loader {loader}')}")
113
117
  else:
114
118
  data(f" Exposed: {dim('none')}")
115
119
 
@@ -163,9 +163,32 @@ def cmd_doctor(args: argparse.Namespace) -> int:
163
163
  logger.info("")
164
164
  if report.ok:
165
165
  logger.info(f" {green('All checks passed')}")
166
- else:
166
+ return 0
167
+
168
+ fixable = report.fixable
169
+ if not getattr(args, "fix", False):
167
170
  n = len(report.warnings)
168
171
  logger.info(f" {yellow(f'{n} warning(s)')}")
172
+ if fixable:
173
+ logger.info(f" {dim(f'{len(fixable)} can be repaired with: ixt doctor --fix')}")
174
+ return 0
175
+
176
+ if not fixable:
177
+ n = len(report.warnings)
178
+ logger.info(f" {yellow(f'{n} warning(s)')} {dim('— none of them have an automatic fix')}")
179
+ return 0
180
+
181
+ for check in fixable:
182
+ assert check.fix is not None
183
+ try:
184
+ logger.info(f" {green('fixed')} {check.name:15s} {check.fix()}")
185
+ except Exception as exc:
186
+ logger.info(f" {yellow('!!')} {check.name:15s} fix failed: {exc}")
187
+
188
+ remaining = len(report.warnings) - len(fixable)
189
+ if remaining > 0:
190
+ logger.info("")
191
+ logger.info(f" {yellow(f'{remaining} warning(s) left')} {dim('— no automatic fix')}")
169
192
 
170
193
  return 0
171
194
 
@@ -13,6 +13,7 @@ from collections.abc import Callable
13
13
  from ixt.cli.cmd_apply import cmd_apply, cmd_export
14
14
  from ixt.cli.cmd_cache import cmd_cache
15
15
  from ixt.cli.cmd_config import cmd_config
16
+ from ixt.cli.cmd_exec import cmd_exec
16
17
  from ixt.cli.cmd_info import cmd_info, cmd_list
17
18
  from ixt.cli.cmd_install import _log_not_found_hint, cmd_add, cmd_install, cmd_uninstall
18
19
  from ixt.cli.cmd_lock import cmd_lock
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "cmd_config",
37
38
  "cmd_doctor",
38
39
  "cmd_environment",
40
+ "cmd_exec",
39
41
  "cmd_export",
40
42
  "cmd_info",
41
43
  "cmd_install",
@@ -58,6 +60,7 @@ COMMANDS: dict[str, Callable] = {
58
60
  "upgrade": cmd_upgrade,
59
61
  "list": cmd_list,
60
62
  "info": cmd_info,
63
+ "exec": cmd_exec,
61
64
  "config": cmd_config,
62
65
  "shell": cmd_shell,
63
66
  "where": cmd_where,
@@ -529,6 +529,11 @@ def build_parser() -> argparse.ArgumentParser:
529
529
  action="store_true",
530
530
  help="Skip GitHub, PyPI, npm, and latest-version network probes",
531
531
  )
532
+ p_doctor.add_argument(
533
+ "--fix",
534
+ action="store_true",
535
+ help="Repair the findings that have an unambiguous remedy",
536
+ )
532
537
 
533
538
  # -- environment -----------------------------------------------------------
534
539
  p_environment = sub.add_parser("environment", help="Show ixt configuration and paths")
@@ -549,6 +554,38 @@ def build_parser() -> argparse.ArgumentParser:
549
554
  p_where.set_defaults(command="where")
550
555
  p_where.add_argument("tool", help="Tool spec or @id:<installed-id>")
551
556
 
557
+ # -- exec ------------------------------------------------------------------
558
+ # Declared for --help and shell completions only. The real parsing is
559
+ # short-circuited in cli.main, because everything after the tool name must
560
+ # reach the tool untouched, '--help' and '--' included.
561
+ p_exec = sub.add_parser(
562
+ "exec",
563
+ help="Run an exposed tool by name, without relying on PATH",
564
+ description=(
565
+ "Run an ixt-exposed tool by name. Useful under sudo, which replaces "
566
+ "PATH with secure_path and cannot see the ixt bin directory. "
567
+ "Every argument after the tool name is forwarded untouched."
568
+ ),
569
+ epilog="Example: ixt exec --sudo rg -- PermitRootLogin /etc/ssh",
570
+ )
571
+ p_exec.set_defaults(command="exec")
572
+ p_exec.add_argument(
573
+ "--sudo",
574
+ action="store_true",
575
+ help="Run the tool through sudo (Unix only)",
576
+ )
577
+ p_exec.add_argument(
578
+ "--dry-run",
579
+ action="store_true",
580
+ help="Print the resolved command without running it",
581
+ )
582
+ p_exec.add_argument("tool", help="Exposed tool name")
583
+ p_exec.add_argument(
584
+ "args",
585
+ nargs=argparse.REMAINDER,
586
+ help="Arguments forwarded to the tool ('--' recommended before them)",
587
+ )
588
+
552
589
  # -- setup -----------------------------------------------------------------
553
590
  p_setup = sub.add_parser("setup", help="Configure shell integration (PATH, ...)")
554
591
  p_setup.set_defaults(command="setup")