ixt-cli 0.9.0__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 (164) hide show
  1. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/PKG-INFO +32 -28
  2. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/README.md +31 -27
  3. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/pyproject.toml +4 -3
  4. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/backends/binary.py +212 -26
  5. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/backends/node.py +51 -18
  6. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/__init__.py +9 -0
  7. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_config.py +69 -51
  8. ixt_cli-0.10.0/src/ixt/cli/cmd_exec.py +71 -0
  9. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_info.py +5 -1
  10. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_install.py +5 -2
  11. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_lock.py +2 -2
  12. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_misc.py +24 -1
  13. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/commands.py +3 -0
  14. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/parser.py +37 -0
  15. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/asset_pattern_cache.py +2 -12
  16. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/env_policy.py +7 -2
  17. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/flags.py +3 -3
  18. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/heuristics.py +86 -4
  19. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/models.py +3 -12
  20. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/apply.py +28 -1
  21. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/cache.py +45 -25
  22. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/discover.py +143 -31
  23. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/doctor.py +56 -0
  24. ixt_cli-0.10.0/src/ixt/core/exec_tool.py +165 -0
  25. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/expose.py +106 -42
  26. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/extract.py +84 -12
  27. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/inject.py +51 -20
  28. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/install.py +70 -80
  29. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/install_local.py +51 -63
  30. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/lockfile.py +214 -20
  31. ixt_cli-0.10.0/src/ixt/core/locks.py +112 -0
  32. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/pathlink.py +52 -1
  33. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/resolve_cache.py +4 -12
  34. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/save.py +62 -25
  35. ixt_cli-0.10.0/src/ixt/core/transactions.py +216 -0
  36. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/upgrade.py +41 -114
  37. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/data/heuristics.toml +8 -2
  38. ixt_cli-0.10.0/src/ixt/libs/atomic.py +56 -0
  39. ixt_cli-0.10.0/src/ixt/libs/elf.py +174 -0
  40. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/net/gitlab_api.py +8 -8
  41. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/net/http.py +124 -3
  42. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/conftest.py +54 -20
  43. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/README.md +6 -4
  44. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/docker/Dockerfile.alpine +1 -1
  45. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/docker/Dockerfile.fedora +1 -1
  46. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/docker/Dockerfile.ubuntu +1 -1
  47. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/run_e2e_incus.sh +1 -1
  48. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/test_e2e.py +18 -8
  49. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/integration/test_lifecycle.py +3 -1
  50. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_apply.py +37 -0
  51. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_argparse_completion.py +1 -1
  52. ixt_cli-0.10.0/tests/unit/test_atomic.py +61 -0
  53. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_binary_backend.py +548 -40
  54. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_cache.py +49 -2
  55. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_cli.py +130 -1
  56. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_discover.py +175 -6
  57. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_doctor.py +94 -0
  58. ixt_cli-0.10.0/tests/unit/test_elf.py +230 -0
  59. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_env_policy.py +18 -0
  60. ixt_cli-0.10.0/tests/unit/test_exec.py +289 -0
  61. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_export.py +1 -0
  62. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_expose.py +76 -0
  63. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_extract.py +60 -0
  64. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_gitlab_api.py +41 -0
  65. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_heuristics.py +53 -6
  66. ixt_cli-0.10.0/tests/unit/test_http.py +339 -0
  67. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_install.py +135 -1
  68. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_install_local.py +30 -4
  69. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_lock_write.py +308 -6
  70. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_lockfile.py +1 -1
  71. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_node_backend.py +111 -9
  72. ixt_cli-0.10.0/tests/unit/test_pathlink.py +145 -0
  73. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_save.py +46 -0
  74. ixt_cli-0.10.0/tests/unit/test_transactions.py +92 -0
  75. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_upgrade.py +107 -48
  76. ixt_cli-0.9.0/src/ixt/core/locks.py +0 -54
  77. ixt_cli-0.9.0/tests/unit/test_http.py +0 -163
  78. ixt_cli-0.9.0/tests/unit/test_pathlink.py +0 -62
  79. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/.gitignore +0 -0
  80. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/LICENSE +0 -0
  81. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/__init__.py +0 -0
  82. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/__main__.py +0 -0
  83. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/backends/__init__.py +0 -0
  84. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/backends/binary_resolver.py +0 -0
  85. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/backends/python.py +0 -0
  86. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/argparse_completion.py +0 -0
  87. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_apply.py +0 -0
  88. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_cache.py +0 -0
  89. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_registry.py +0 -0
  90. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/cmd_upgrade.py +0 -0
  91. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/cli/render.py +0 -0
  92. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/__init__.py +0 -0
  93. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/asset_index.py +0 -0
  94. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/fs_policy.py +0 -0
  95. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/lockfile.py +0 -0
  96. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/registry.py +0 -0
  97. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/settings.py +0 -0
  98. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/setup_toml.py +0 -0
  99. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/config/toml.py +0 -0
  100. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/__init__.py +0 -0
  101. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/apply_actions.py +0 -0
  102. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/backend.py +0 -0
  103. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/bootstrap.py +0 -0
  104. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/export.py +0 -0
  105. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/hooks.py +0 -0
  106. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/identity.py +0 -0
  107. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/resolution_stats.py +0 -0
  108. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/resolve.py +0 -0
  109. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/runtimes.py +0 -0
  110. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/setup_completions.py +0 -0
  111. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/core/setup_path.py +0 -0
  112. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/data/__init__.py +0 -0
  113. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/data/asset_index.json +0 -0
  114. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/data/registry.toml +0 -0
  115. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/__init__.py +0 -0
  116. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/constants.py +0 -0
  117. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/fmt.py +0 -0
  118. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/logger.py +0 -0
  119. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/output.py +0 -0
  120. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/req_spec.py +0 -0
  121. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/semver.py +0 -0
  122. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/shell.py +0 -0
  123. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/libs/style.py +0 -0
  124. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/net/__init__.py +0 -0
  125. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/net/github_api.py +0 -0
  126. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/net/npm.py +0 -0
  127. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/net/pypi.py +0 -0
  128. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/net/source.py +0 -0
  129. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/platform/__init__.py +0 -0
  130. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/src/ixt/platform/win.py +0 -0
  131. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/__init__.py +0 -0
  132. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/conftest.py +0 -0
  133. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/playground_incus.sh +0 -0
  134. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/run_e2e_docker.sh +0 -0
  135. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/e2e/runtime_matrix.sh +0 -0
  136. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/integration/__init__.py +0 -0
  137. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/integration/test_policy_runtime.py +0 -0
  138. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/__init__.py +0 -0
  139. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_asset_index.py +0 -0
  140. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_asset_pattern_cache.py +0 -0
  141. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_backend.py +0 -0
  142. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_bootstrap.py +0 -0
  143. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_flags.py +0 -0
  144. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_fs_policy.py +0 -0
  145. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_github_api.py +0 -0
  146. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_hooks.py +0 -0
  147. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_inject.py +0 -0
  148. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_models.py +0 -0
  149. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_namespacing.py +0 -0
  150. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_output.py +0 -0
  151. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_platform.py +0 -0
  152. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_python_backend.py +0 -0
  153. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_registry.py +0 -0
  154. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_req_spec.py +0 -0
  155. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_resolve.py +0 -0
  156. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_resolve_cache.py +0 -0
  157. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_runtimes.py +0 -0
  158. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_semver.py +0 -0
  159. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_settings.py +0 -0
  160. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_setup_path.py +0 -0
  161. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_setup_toml.py +0 -0
  162. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_source.py +0 -0
  163. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_style.py +0 -0
  164. {ixt_cli-0.9.0 → ixt_cli-0.10.0}/tests/unit/test_toml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ixt-cli
3
- Version: 0.9.0
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
@@ -51,10 +51,10 @@ Description-Content-Type: text/markdown
51
51
  *Zero third-party deps. Zero system pollution. One command, any ecosystem.*
52
52
 
53
53
  - **Day-one GitHub releases** — install a tool the day it ships, without
54
- waiting for a formula, registry entry, or plugin.
54
+ waiting for a formula, registry entry, or plugin.
55
55
  - **No system runtime needed** — ixt is pure Python stdlib; `uv` and `bun` are
56
- bootstrapped on first use, so the host does not need Node, npm, or a package
57
- manager pre-installed.
56
+ bootstrapped on first use, so the host does not need Node, npm, or a package
57
+ manager pre-installed.
58
58
 
59
59
  ```bash
60
60
  ixt tool install ruff # Python (PyPI)
@@ -202,40 +202,44 @@ ixt tool apply --locked --strict
202
202
 
203
203
  `--locked` replays the exact lock instead of resolving newer versions.
204
204
  `--strict` fails before changing the machine if `ixt.toml` and `ixt.lock`
205
- diverge.
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.
206
209
 
207
210
  ## 🧰 Commands
208
211
 
209
212
  | Command | Description |
210
213
  | ---------------------------------------------- | --------------------------------------------------------------------- |
211
- | `ixt tool add <pkg>` | Add a tool to ixt.toml without installing |
212
- | `ixt tool install <pkg>` | Install a tool in isolation |
213
- | `ixt tool install <pkg> --bare` | Install the env without exposing any binary |
214
- | `ixt tool uninstall <pkg>` | Remove a tool |
215
- | `ixt tool list` | List installed tools |
216
- | `ixt tool info <pkg>` | Show tool details |
217
- | `ixt tool upgrade <pkg>` | Upgrade a tool |
218
- | `ixt tool upgrade --all` | Upgrade all tools |
219
- | `ixt tool apply` | Sync tools from declarative config |
220
- | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
221
- | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
222
- | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
223
- | `ixt lock check` | Fail if ixt.lock is missing or stale |
224
- | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
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 |
225
228
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
226
- | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
227
- | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
228
- | `ixt tool config <pkg> uninject <dep>` | Remove an injected package |
229
- | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
230
- | `ixt tool shell <pkg>` | Open a subshell in tool's env |
231
- | `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 |
235
+ | `ixt exec [--sudo] <tool> -- <args...>` | Run an exposed tool by name, without relying on PATH |
232
236
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
233
237
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
234
238
  | `ixt cache info` | Show cache paths and sizes |
235
239
  | `ixt cache prune` | Keep current + previous indexed download artifacts per repository |
236
240
  | `ixt cache clear {downloads,metadata,tmp,all}` | Wipe cache(s); never touches installed tools |
237
- | `ixt runtime info` | Show ixt-managed runtime state |
238
- | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
241
+ | `ixt runtime info` | Show ixt-managed runtime state |
242
+ | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
239
243
  | `ixt setup path` | Add `$IXT_HOME/installed/bin` to PATH |
240
244
  | `ixt setup completions --shell zsh` | Print shell completion code for ixt |
241
245
 
@@ -285,7 +289,7 @@ ixt is **not** a runtime version manager (mise, proto, asdf cover that). It mana
285
289
  git clone https://gitlab.com/pytgaen-group/ixt.git && cd ixt
286
290
  uv sync --group dev
287
291
 
288
- uv run pytest # test suite
292
+ uv run pytest # hermetic unit suite
289
293
  uv run ruff check src/ tests/ # lint
290
294
  uv run ruff format src/ tests/ # format
291
295
  ```
@@ -22,10 +22,10 @@
22
22
  *Zero third-party deps. Zero system pollution. One command, any ecosystem.*
23
23
 
24
24
  - **Day-one GitHub releases** — install a tool the day it ships, without
25
- waiting for a formula, registry entry, or plugin.
25
+ waiting for a formula, registry entry, or plugin.
26
26
  - **No system runtime needed** — ixt is pure Python stdlib; `uv` and `bun` are
27
- bootstrapped on first use, so the host does not need Node, npm, or a package
28
- manager pre-installed.
27
+ bootstrapped on first use, so the host does not need Node, npm, or a package
28
+ manager pre-installed.
29
29
 
30
30
  ```bash
31
31
  ixt tool install ruff # Python (PyPI)
@@ -173,40 +173,44 @@ ixt tool apply --locked --strict
173
173
 
174
174
  `--locked` replays the exact lock instead of resolving newer versions.
175
175
  `--strict` fails before changing the machine if `ixt.toml` and `ixt.lock`
176
- diverge.
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.
177
180
 
178
181
  ## 🧰 Commands
179
182
 
180
183
  | Command | Description |
181
184
  | ---------------------------------------------- | --------------------------------------------------------------------- |
182
- | `ixt tool add <pkg>` | Add a tool to ixt.toml without installing |
183
- | `ixt tool install <pkg>` | Install a tool in isolation |
184
- | `ixt tool install <pkg> --bare` | Install the env without exposing any binary |
185
- | `ixt tool uninstall <pkg>` | Remove a tool |
186
- | `ixt tool list` | List installed tools |
187
- | `ixt tool info <pkg>` | Show tool details |
188
- | `ixt tool upgrade <pkg>` | Upgrade a tool |
189
- | `ixt tool upgrade --all` | Upgrade all tools |
190
- | `ixt tool apply` | Sync tools from declarative config |
191
- | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
192
- | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
193
- | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
194
- | `ixt lock check` | Fail if ixt.lock is missing or stale |
195
- | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
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 |
196
199
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
197
- | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
198
- | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
199
- | `ixt tool config <pkg> uninject <dep>` | Remove an injected package |
200
- | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
201
- | `ixt tool shell <pkg>` | Open a subshell in tool's env |
202
- | `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 |
206
+ | `ixt exec [--sudo] <tool> -- <args...>` | Run an exposed tool by name, without relying on PATH |
203
207
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
204
208
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
205
209
  | `ixt cache info` | Show cache paths and sizes |
206
210
  | `ixt cache prune` | Keep current + previous indexed download artifacts per repository |
207
211
  | `ixt cache clear {downloads,metadata,tmp,all}` | Wipe cache(s); never touches installed tools |
208
- | `ixt runtime info` | Show ixt-managed runtime state |
209
- | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
212
+ | `ixt runtime info` | Show ixt-managed runtime state |
213
+ | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
210
214
  | `ixt setup path` | Add `$IXT_HOME/installed/bin` to PATH |
211
215
  | `ixt setup completions --shell zsh` | Print shell completion code for ixt |
212
216
 
@@ -256,7 +260,7 @@ ixt is **not** a runtime version manager (mise, proto, asdf cover that). It mana
256
260
  git clone https://gitlab.com/pytgaen-group/ixt.git && cd ixt
257
261
  uv sync --group dev
258
262
 
259
- uv run pytest # test suite
263
+ uv run pytest # hermetic unit suite
260
264
  uv run ruff check src/ tests/ # lint
261
265
  uv run ruff format src/ tests/ # format
262
266
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ixt-cli"
7
- version = "0.9.0" # 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"
@@ -61,14 +61,15 @@ include = [
61
61
  # Testing
62
62
  [tool.pytest.ini_options]
63
63
  minversion = "7.0"
64
- testpaths = ["tests"]
64
+ testpaths = ["tests/unit"]
65
65
  python_files = ["test_*.py"]
66
66
  python_classes = ["Test*"]
67
67
  python_functions = ["test_*"]
68
- addopts = "-ra --strict-markers"
68
+ addopts = '-ra --strict-markers -m "not slow and not network"'
69
69
  markers = [
70
70
  "unit: unit tests",
71
71
  "integration: integration tests",
72
+ "e2e: end-to-end tests against an installed ixt CLI",
72
73
  "platform: platform-specific tests (linux, macos, windows)",
73
74
  "slow: tests that bootstrap external runtimes (uv, bun) — skip by default with -m 'not slow'",
74
75
  "network: tests that hit the public internet (e.g. real GitHub releases) — opt in with -m 'network'",
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import json
7
+ import os
6
8
  import re
7
9
  from dataclasses import dataclass, field
8
10
  from pathlib import Path
@@ -18,6 +20,7 @@ from ixt.config.flags import is_setup_toml_enabled
18
20
  from ixt.config.heuristics import (
19
21
  get_name_suffix_tokens,
20
22
  get_platform_aliases,
23
+ is_asset_arch_compatible,
21
24
  load_heuristics,
22
25
  select_asset,
23
26
  )
@@ -29,7 +32,10 @@ from ixt.config.settings import Settings, get_settings
29
32
  from ixt.core.backend import BackendType
30
33
  from ixt.core.discover import find_executables
31
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
32
37
  from ixt.libs.logger import get_logger, get_verbosity
38
+ from ixt.net.http import MAX_DOWNLOAD_SIZE, DownloadTooLargeError, get_range
33
39
  from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
34
40
  from ixt.platform import get_arch, get_os
35
41
 
@@ -45,6 +51,9 @@ __all__ = [
45
51
  "make_source",
46
52
  ]
47
53
 
54
+ BINARY_ASSET_CACHE_SCHEMA = 2
55
+ _CACHE_ARCHIVE_SUFFIXES = (".tar.bz2", ".tar.gz", ".tar.xz", ".tgz", ".txz", ".zip")
56
+
48
57
 
49
58
  # 3.10 lacks typing.NotRequired (added in 3.11) and the project bans
50
59
  # third-party deps (typing_extensions). The required-base + total=False
@@ -82,14 +91,27 @@ class SetupMetadata(_SetupMetadataRequired, total=False):
82
91
  class AssetSelectionError(RuntimeError):
83
92
  """Raised when no suitable asset could be selected for the current platform."""
84
93
 
85
- 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
+ ):
86
101
  self.spec = spec
87
102
  self.release_tag = release_tag
88
103
  self.asset_names = asset_names
104
+ self.unusable = unusable or []
89
105
  names = ", ".join(asset_names[:10]) or "<none>"
90
- super().__init__(
106
+ message = (
91
107
  f"No suitable asset found for '{spec}' (release {release_tag}). Available: {names}"
92
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)
93
115
 
94
116
 
95
117
  class BinaryPlatformError(RuntimeError):
@@ -267,10 +289,51 @@ def _cache_dir_for(settings: Settings, repo_spec: RepoSpec) -> Path:
267
289
  return settings.cache_dir / host / f"{repo_spec.owner}-{repo_spec.repo}"
268
290
 
269
291
 
270
- def _cached_asset(cache_dir: Path, asset_name: str) -> Path | None:
271
- """Return the cached asset path if it exists."""
272
- path = cache_dir / asset_name
273
- if path.exists() and path.stat().st_size > 0:
292
+ def _asset_cache_key(repo_spec: RepoSpec, asset: Asset, *, tag: str) -> str:
293
+ """Return the stable cache identity for one exact release asset."""
294
+ identity = json.dumps(
295
+ {
296
+ "schema": BINARY_ASSET_CACHE_SCHEMA,
297
+ "platform": repo_spec.platform,
298
+ "host": repo_spec.host,
299
+ "owner": repo_spec.owner,
300
+ "repo": repo_spec.repo,
301
+ "tag": tag,
302
+ "asset": asset.name,
303
+ "url": asset.url,
304
+ },
305
+ sort_keys=True,
306
+ separators=(",", ":"),
307
+ )
308
+ return hashlib.sha256(identity.encode()).hexdigest()
309
+
310
+
311
+ def _asset_cache_path(
312
+ downloads_dir: Path,
313
+ repo_spec: RepoSpec,
314
+ asset: Asset,
315
+ *,
316
+ tag: str,
317
+ ) -> Path:
318
+ """Return a portable path for one exact release asset."""
319
+ lower_name = asset.name.lower()
320
+ suffix = next((item for item in _CACHE_ARCHIVE_SUFFIXES if lower_name.endswith(item)), "")
321
+ if not suffix:
322
+ match = re.search(r"\.[A-Za-z0-9]{1,10}$", asset.name)
323
+ suffix = match.group(0) if match is not None else ""
324
+ key = _asset_cache_key(repo_spec, asset, tag=tag)
325
+ return downloads_dir / f"assets-v{BINARY_ASSET_CACHE_SCHEMA}" / key / f"asset{suffix}"
326
+
327
+
328
+ def _cached_asset(path: Path, expected_size: int = 0) -> Path | None:
329
+ """Return *path* when it is a non-empty cache entry of the expected size."""
330
+ try:
331
+ if not path.is_file():
332
+ return None
333
+ size = path.stat().st_size
334
+ except OSError:
335
+ return None
336
+ if 0 < size <= MAX_DOWNLOAD_SIZE and (expected_size <= 0 or size == expected_size):
274
337
  return path
275
338
  return None
276
339
 
@@ -533,9 +596,30 @@ class BinaryBackend:
533
596
  log.debug(f"user-forced asset_pattern: {asset_pattern!r}")
534
597
  selection_pattern = _selection_pattern(effective_pattern, release.tag)
535
598
  if locked_binary is None:
536
- 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
+ )
537
615
  else:
538
616
  asset = release.assets[0]
617
+ if not is_asset_arch_compatible(
618
+ asset.name,
619
+ heuristics=load_heuristics(),
620
+ declared_platform=locked_binary.platform,
621
+ ):
622
+ raise AssetSelectionError(spec, release.tag, [asset.name])
539
623
  log.debug(f"selected asset: {asset.name}")
540
624
 
541
625
  archive_path = self._download_asset(
@@ -680,23 +764,110 @@ class BinaryBackend:
680
764
  asset_pattern: str | None,
681
765
  tool_name: str,
682
766
  main_spec: str,
767
+ *,
768
+ repo_spec: RepoSpec | None = None,
683
769
  ) -> Asset:
770
+ log = get_logger("binary")
684
771
  heuristics = load_heuristics()
685
- asset = select_asset(
686
- release.assets,
687
- tool_name,
688
- _strip_v_prefix(release.tag),
689
- heuristics=heuristics,
690
- asset_pattern=asset_pattern,
691
- verbose=get_verbosity(),
692
- )
693
- if asset is None:
694
- raise AssetSelectionError(
695
- main_spec,
696
- release.tag,
697
- [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(),
698
783
  )
699
- 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
700
871
 
701
872
  def uninstall_package(self, env_dir: Path, package: str) -> None:
702
873
  """No-op — binary tools are atomic (uninstall removes the whole env)."""
@@ -950,15 +1121,28 @@ class BinaryBackend:
950
1121
  upgrade: bool = False,
951
1122
  ) -> Path:
952
1123
  """Download an asset, using cache when available."""
953
- cached = _cached_asset(cache_dir, asset.name)
1124
+ if asset.size > MAX_DOWNLOAD_SIZE:
1125
+ raise DownloadTooLargeError(asset.url, MAX_DOWNLOAD_SIZE, asset.size)
1126
+
1127
+ cache_key = _asset_cache_key(repo_spec, asset, tag=tag)
1128
+ dest = _asset_cache_path(self.settings.downloads_dir, repo_spec, asset, tag=tag)
1129
+ cached = _cached_asset(dest, asset.size)
954
1130
  if cached is not None and not upgrade:
955
- self._record_download(repo_spec, asset, cached, tag=tag)
1131
+ self._record_download(repo_spec, asset, cached, tag=tag, cache_key=cache_key)
956
1132
  return cached
957
1133
 
958
- cache_dir.mkdir(parents=True, exist_ok=True)
959
- dest = cache_dir / asset.name
1134
+ dest.parent.mkdir(parents=True, exist_ok=True)
1135
+ dest.unlink(missing_ok=True)
960
1136
  source.download_asset(asset.url, dest)
961
- self._record_download(repo_spec, asset, dest, tag=tag)
1137
+ if dest.is_file() and dest.stat().st_size > MAX_DOWNLOAD_SIZE:
1138
+ actual_size = dest.stat().st_size
1139
+ dest.unlink(missing_ok=True)
1140
+ raise DownloadTooLargeError(asset.url, MAX_DOWNLOAD_SIZE, actual_size)
1141
+ if _cached_asset(dest, asset.size) is None:
1142
+ dest.unlink(missing_ok=True)
1143
+ expected = f"{asset.size} bytes" if asset.size > 0 else "a non-empty file"
1144
+ raise RuntimeError(f"Downloaded asset {asset.name!r} does not match {expected}")
1145
+ self._record_download(repo_spec, asset, dest, tag=tag, cache_key=cache_key)
962
1146
  return dest
963
1147
 
964
1148
  def _record_download(
@@ -968,6 +1152,7 @@ class BinaryBackend:
968
1152
  path: Path,
969
1153
  *,
970
1154
  tag: str,
1155
+ cache_key: str,
971
1156
  ) -> None:
972
1157
  """Index the downloaded binary asset for later cache pruning."""
973
1158
  from ixt.core.cache import record_download
@@ -984,4 +1169,5 @@ class BinaryBackend:
984
1169
  asset_url=asset.url,
985
1170
  asset_size=asset.size,
986
1171
  path=path,
1172
+ cache_key=cache_key,
987
1173
  )