ixt-cli 0.9.0__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 (158) hide show
  1. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/PKG-INFO +31 -28
  2. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/README.md +30 -27
  3. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/pyproject.toml +4 -3
  4. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/backends/binary.py +77 -9
  5. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/backends/node.py +2 -4
  6. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_config.py +69 -51
  7. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_install.py +5 -2
  8. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_lock.py +2 -2
  9. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/asset_pattern_cache.py +2 -12
  10. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/env_policy.py +7 -2
  11. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/flags.py +3 -3
  12. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/heuristics.py +86 -4
  13. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/models.py +3 -12
  14. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/apply.py +28 -1
  15. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/cache.py +45 -25
  16. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/discover.py +119 -30
  17. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/expose.py +106 -42
  18. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/extract.py +84 -12
  19. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/inject.py +51 -20
  20. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/install.py +70 -80
  21. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/install_local.py +51 -63
  22. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/lockfile.py +214 -20
  23. ixt_cli-0.9.1/src/ixt/core/locks.py +112 -0
  24. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/pathlink.py +52 -1
  25. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/resolve_cache.py +4 -12
  26. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/save.py +62 -25
  27. ixt_cli-0.9.1/src/ixt/core/transactions.py +216 -0
  28. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/upgrade.py +22 -106
  29. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/data/heuristics.toml +8 -2
  30. ixt_cli-0.9.1/src/ixt/libs/atomic.py +56 -0
  31. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/net/gitlab_api.py +8 -8
  32. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/net/http.py +86 -3
  33. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/conftest.py +54 -20
  34. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/README.md +6 -4
  35. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/docker/Dockerfile.alpine +1 -1
  36. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/docker/Dockerfile.fedora +1 -1
  37. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/docker/Dockerfile.ubuntu +1 -1
  38. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/run_e2e_incus.sh +1 -1
  39. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/test_e2e.py +18 -8
  40. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/integration/test_lifecycle.py +3 -1
  41. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_apply.py +37 -0
  42. ixt_cli-0.9.1/tests/unit/test_atomic.py +61 -0
  43. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_binary_backend.py +292 -40
  44. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_cache.py +49 -2
  45. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_cli.py +40 -1
  46. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_discover.py +112 -6
  47. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_env_policy.py +18 -0
  48. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_export.py +1 -0
  49. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_expose.py +76 -0
  50. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_extract.py +60 -0
  51. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_gitlab_api.py +41 -0
  52. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_heuristics.py +53 -6
  53. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_http.py +127 -0
  54. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_install.py +135 -1
  55. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_install_local.py +30 -4
  56. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_lock_write.py +308 -6
  57. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_lockfile.py +1 -1
  58. ixt_cli-0.9.1/tests/unit/test_pathlink.py +145 -0
  59. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_save.py +46 -0
  60. ixt_cli-0.9.1/tests/unit/test_transactions.py +92 -0
  61. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_upgrade.py +33 -20
  62. ixt_cli-0.9.0/src/ixt/core/locks.py +0 -54
  63. ixt_cli-0.9.0/tests/unit/test_pathlink.py +0 -62
  64. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/.gitignore +0 -0
  65. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/LICENSE +0 -0
  66. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/__init__.py +0 -0
  67. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/__main__.py +0 -0
  68. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/backends/__init__.py +0 -0
  69. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/backends/binary_resolver.py +0 -0
  70. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/backends/python.py +0 -0
  71. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/__init__.py +0 -0
  72. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/argparse_completion.py +0 -0
  73. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_apply.py +0 -0
  74. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_cache.py +0 -0
  75. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_info.py +0 -0
  76. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_misc.py +0 -0
  77. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_registry.py +0 -0
  78. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/cmd_upgrade.py +0 -0
  79. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/commands.py +0 -0
  80. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/parser.py +0 -0
  81. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/cli/render.py +0 -0
  82. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/__init__.py +0 -0
  83. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/asset_index.py +0 -0
  84. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/fs_policy.py +0 -0
  85. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/lockfile.py +0 -0
  86. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/registry.py +0 -0
  87. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/settings.py +0 -0
  88. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/setup_toml.py +0 -0
  89. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/config/toml.py +0 -0
  90. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/__init__.py +0 -0
  91. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/apply_actions.py +0 -0
  92. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/backend.py +0 -0
  93. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/bootstrap.py +0 -0
  94. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/doctor.py +0 -0
  95. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/export.py +0 -0
  96. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/hooks.py +0 -0
  97. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/identity.py +0 -0
  98. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/resolution_stats.py +0 -0
  99. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/resolve.py +0 -0
  100. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/runtimes.py +0 -0
  101. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/setup_completions.py +0 -0
  102. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/core/setup_path.py +0 -0
  103. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/data/__init__.py +0 -0
  104. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/data/asset_index.json +0 -0
  105. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/data/registry.toml +0 -0
  106. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/__init__.py +0 -0
  107. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/constants.py +0 -0
  108. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/fmt.py +0 -0
  109. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/logger.py +0 -0
  110. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/output.py +0 -0
  111. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/req_spec.py +0 -0
  112. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/semver.py +0 -0
  113. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/shell.py +0 -0
  114. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/libs/style.py +0 -0
  115. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/net/__init__.py +0 -0
  116. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/net/github_api.py +0 -0
  117. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/net/npm.py +0 -0
  118. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/net/pypi.py +0 -0
  119. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/net/source.py +0 -0
  120. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/platform/__init__.py +0 -0
  121. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/src/ixt/platform/win.py +0 -0
  122. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/__init__.py +0 -0
  123. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/conftest.py +0 -0
  124. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/playground_incus.sh +0 -0
  125. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/run_e2e_docker.sh +0 -0
  126. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/e2e/runtime_matrix.sh +0 -0
  127. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/integration/__init__.py +0 -0
  128. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/integration/test_policy_runtime.py +0 -0
  129. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/__init__.py +0 -0
  130. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_argparse_completion.py +0 -0
  131. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_asset_index.py +0 -0
  132. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_asset_pattern_cache.py +0 -0
  133. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_backend.py +0 -0
  134. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_bootstrap.py +0 -0
  135. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_doctor.py +0 -0
  136. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_flags.py +0 -0
  137. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_fs_policy.py +0 -0
  138. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_github_api.py +0 -0
  139. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_hooks.py +0 -0
  140. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_inject.py +0 -0
  141. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_models.py +0 -0
  142. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_namespacing.py +0 -0
  143. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_node_backend.py +0 -0
  144. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_output.py +0 -0
  145. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_platform.py +0 -0
  146. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_python_backend.py +0 -0
  147. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_registry.py +0 -0
  148. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_req_spec.py +0 -0
  149. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_resolve.py +0 -0
  150. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_resolve_cache.py +0 -0
  151. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_runtimes.py +0 -0
  152. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_semver.py +0 -0
  153. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_settings.py +0 -0
  154. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_setup_path.py +0 -0
  155. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_setup_toml.py +0 -0
  156. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_source.py +0 -0
  157. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/tests/unit/test_style.py +0 -0
  158. {ixt_cli-0.9.0 → ixt_cli-0.9.1}/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.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
@@ -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,43 @@ 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 |
232
235
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
233
236
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
234
237
  | `ixt cache info` | Show cache paths and sizes |
235
238
  | `ixt cache prune` | Keep current + previous indexed download artifacts per repository |
236
239
  | `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 |
240
+ | `ixt runtime info` | Show ixt-managed runtime state |
241
+ | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
239
242
  | `ixt setup path` | Add `$IXT_HOME/installed/bin` to PATH |
240
243
  | `ixt setup completions --shell zsh` | Print shell completion code for ixt |
241
244
 
@@ -285,7 +288,7 @@ ixt is **not** a runtime version manager (mise, proto, asdf cover that). It mana
285
288
  git clone https://gitlab.com/pytgaen-group/ixt.git && cd ixt
286
289
  uv sync --group dev
287
290
 
288
- uv run pytest # test suite
291
+ uv run pytest # hermetic unit suite
289
292
  uv run ruff check src/ tests/ # lint
290
293
  uv run ruff format src/ tests/ # format
291
294
  ```
@@ -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,43 @@ 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 |
203
206
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
204
207
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
205
208
  | `ixt cache info` | Show cache paths and sizes |
206
209
  | `ixt cache prune` | Keep current + previous indexed download artifacts per repository |
207
210
  | `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 |
211
+ | `ixt runtime info` | Show ixt-managed runtime state |
212
+ | `ixt runtime prune [--all]` | Remove unused or all ixt-managed runtimes |
210
213
  | `ixt setup path` | Add `$IXT_HOME/installed/bin` to PATH |
211
214
  | `ixt setup completions --shell zsh` | Print shell completion code for ixt |
212
215
 
@@ -256,7 +259,7 @@ ixt is **not** a runtime version manager (mise, proto, asdf cover that). It mana
256
259
  git clone https://gitlab.com/pytgaen-group/ixt.git && cd ixt
257
260
  uv sync --group dev
258
261
 
259
- uv run pytest # test suite
262
+ uv run pytest # hermetic unit suite
260
263
  uv run ruff check src/ tests/ # lint
261
264
  uv run ruff format src/ tests/ # format
262
265
  ```
@@ -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.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"
@@ -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,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,6 +19,7 @@ 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
  )
@@ -30,6 +32,7 @@ from ixt.core.backend import BackendType
30
32
  from ixt.core.discover import find_executables
31
33
  from ixt.core.extract import extract, is_archive
32
34
  from ixt.libs.logger import get_logger, get_verbosity
35
+ from ixt.net.http import MAX_DOWNLOAD_SIZE, DownloadTooLargeError
33
36
  from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
34
37
  from ixt.platform import get_arch, get_os
35
38
 
@@ -45,6 +48,9 @@ __all__ = [
45
48
  "make_source",
46
49
  ]
47
50
 
51
+ BINARY_ASSET_CACHE_SCHEMA = 2
52
+ _CACHE_ARCHIVE_SUFFIXES = (".tar.bz2", ".tar.gz", ".tar.xz", ".tgz", ".txz", ".zip")
53
+
48
54
 
49
55
  # 3.10 lacks typing.NotRequired (added in 3.11) and the project bans
50
56
  # third-party deps (typing_extensions). The required-base + total=False
@@ -267,10 +273,51 @@ def _cache_dir_for(settings: Settings, repo_spec: RepoSpec) -> Path:
267
273
  return settings.cache_dir / host / f"{repo_spec.owner}-{repo_spec.repo}"
268
274
 
269
275
 
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:
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):
274
321
  return path
275
322
  return None
276
323
 
@@ -536,6 +583,12 @@ class BinaryBackend:
536
583
  asset = self._select_asset(release, selection_pattern, tool_name, spec)
537
584
  else:
538
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])
539
592
  log.debug(f"selected asset: {asset.name}")
540
593
 
541
594
  archive_path = self._download_asset(
@@ -950,15 +1003,28 @@ class BinaryBackend:
950
1003
  upgrade: bool = False,
951
1004
  ) -> Path:
952
1005
  """Download an asset, using cache when available."""
953
- 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)
954
1012
  if cached is not None and not upgrade:
955
- self._record_download(repo_spec, asset, cached, tag=tag)
1013
+ self._record_download(repo_spec, asset, cached, tag=tag, cache_key=cache_key)
956
1014
  return cached
957
1015
 
958
- cache_dir.mkdir(parents=True, exist_ok=True)
959
- dest = cache_dir / asset.name
1016
+ dest.parent.mkdir(parents=True, exist_ok=True)
1017
+ dest.unlink(missing_ok=True)
960
1018
  source.download_asset(asset.url, dest)
961
- 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)
962
1028
  return dest
963
1029
 
964
1030
  def _record_download(
@@ -968,6 +1034,7 @@ class BinaryBackend:
968
1034
  path: Path,
969
1035
  *,
970
1036
  tag: str,
1037
+ cache_key: str,
971
1038
  ) -> None:
972
1039
  """Index the downloaded binary asset for later cache pruning."""
973
1040
  from ixt.core.cache import record_download
@@ -984,4 +1051,5 @@ class BinaryBackend:
984
1051
  asset_url=asset.url,
985
1052
  asset_size=asset.size,
986
1053
  path=path,
1054
+ cache_key=cache_key,
987
1055
  )
@@ -114,8 +114,7 @@ def _write_metadata(
114
114
  "package_name": package_name,
115
115
  "version": version,
116
116
  "binaries": {
117
- name: _encode_path_under_env(path, env_dir)
118
- for name, path in binaries.items()
117
+ name: _encode_path_under_env(path, env_dir) for name, path in binaries.items()
119
118
  },
120
119
  "used_npm_fallback": used_npm_fallback,
121
120
  "runtime": runtime,
@@ -136,8 +135,7 @@ def read_metadata(env_dir: Path) -> dict | None:
136
135
  binaries = data.get("binaries")
137
136
  if isinstance(binaries, dict):
138
137
  data["binaries"] = {
139
- name: _decode_path_from_env(path, env_dir)
140
- for name, path in binaries.items()
138
+ name: _decode_path_from_env(path, env_dir) for name, path in binaries.items()
141
139
  }
142
140
  return data
143
141
 
@@ -18,6 +18,7 @@ _ALL_TOOLS_SENTINEL = "__all__"
18
18
  def cmd_config(args: argparse.Namespace) -> int:
19
19
  """Dispatch ``ixt tool config <target> <action>`` to the right handler."""
20
20
  logger = get_logger("config")
21
+ settings = None
21
22
  if args.tool != _ALL_TOOLS_SENTINEL:
22
23
  from ixt.config.settings import get_settings
23
24
  from ixt.core.install import resolve_tool_name
@@ -28,7 +29,16 @@ def cmd_config(args: argparse.Namespace) -> int:
28
29
  except ValueError as e:
29
30
  logger.error(str(e))
30
31
  return 1
31
- return _CONFIG_ACTIONS[args.config_action](args, logger)
32
+ try:
33
+ if settings is not None:
34
+ from ixt.core.locks import tool_lock
35
+
36
+ with tool_lock(args.tool, settings=settings):
37
+ return _CONFIG_ACTIONS[args.config_action](args, logger)
38
+ return _CONFIG_ACTIONS[args.config_action](args, logger)
39
+ except ValueError as e:
40
+ logger.error(str(e))
41
+ return 1
32
42
 
33
43
 
34
44
  def _cmd_config_expose(args: argparse.Namespace, logger) -> int:
@@ -126,28 +136,32 @@ def _cmd_config_env(args: argparse.Namespace, logger) -> int:
126
136
  return 0
127
137
 
128
138
  from ixt.config.env_policy import apply_policy
129
-
130
- if action == "reset":
131
- record.env_base = "all"
132
- record.env_allow = []
133
- record.env_deny = {}
134
- elif action == "base":
135
- record.env_base = args.value
136
- elif action == "allow":
137
- for pat in args.patterns:
138
- if pat not in record.env_allow:
139
- record.env_allow.append(pat)
140
- elif action == "deny":
141
- exceptions = getattr(args, "exceptions", [])
142
- for pat in args.patterns:
143
- existing = record.env_deny.get(pat, {})
144
- existing_except = existing.get("except", [])
145
- record.env_deny[pat] = {"except": sorted(set(existing_except) | set(exceptions))}
146
-
147
- record.save_json(meta_file)
148
-
149
- if record.exposed_bins:
150
- apply_policy(record, settings.bin_dir)
139
+ from ixt.core.locks import bin_lock, tool_lock
140
+
141
+ with tool_lock(args.tool, settings=settings):
142
+ record = ToolRecord.load_json(meta_file)
143
+ if action == "reset":
144
+ record.env_base = "all"
145
+ record.env_allow = []
146
+ record.env_deny = {}
147
+ elif action == "base":
148
+ record.env_base = args.value
149
+ elif action == "allow":
150
+ for pat in args.patterns:
151
+ if pat not in record.env_allow:
152
+ record.env_allow.append(pat)
153
+ elif action == "deny":
154
+ exceptions = getattr(args, "exceptions", [])
155
+ for pat in args.patterns:
156
+ existing = record.env_deny.get(pat, {})
157
+ existing_except = existing.get("except", [])
158
+ record.env_deny[pat] = {"except": sorted(set(existing_except) | set(exceptions))}
159
+
160
+ record.save_json(meta_file)
161
+
162
+ if record.exposed_bins:
163
+ with bin_lock(settings=settings):
164
+ apply_policy(record, settings.bin_dir)
151
165
 
152
166
  _warn_env_policy_without_bwrap(record, logger)
153
167
  _print_env_policy(record, logger)
@@ -232,34 +246,38 @@ def _cmd_config_fs(args: argparse.Namespace, logger) -> int:
232
246
  return 0
233
247
 
234
248
  from ixt.config.env_policy import apply_policy
235
-
236
- if action in ("ro", "rw", "scratch"):
237
- _auto_promote_fs_base(record, logger)
238
-
239
- if action == "reset":
240
- record.fs_base = "all"
241
- record.fs_ro = []
242
- record.fs_rw = []
243
- record.fs_scratch = []
244
- elif action == "base":
245
- record.fs_base = args.value
246
- elif action == "ro":
247
- for p in args.paths:
248
- if p not in record.fs_ro:
249
- record.fs_ro.append(p)
250
- elif action == "rw":
251
- for p in args.paths:
252
- if p not in record.fs_rw:
253
- record.fs_rw.append(p)
254
- elif action == "scratch":
255
- for p in args.paths:
256
- if p not in record.fs_scratch:
257
- record.fs_scratch.append(p)
258
-
259
- record.save_json(meta_file)
260
-
261
- if record.exposed_bins:
262
- apply_policy(record, settings.bin_dir)
249
+ from ixt.core.locks import bin_lock, tool_lock
250
+
251
+ with tool_lock(args.tool, settings=settings):
252
+ record = ToolRecord.load_json(meta_file)
253
+ if action in ("ro", "rw", "scratch"):
254
+ _auto_promote_fs_base(record, logger)
255
+
256
+ if action == "reset":
257
+ record.fs_base = "all"
258
+ record.fs_ro = []
259
+ record.fs_rw = []
260
+ record.fs_scratch = []
261
+ elif action == "base":
262
+ record.fs_base = args.value
263
+ elif action == "ro":
264
+ for p in args.paths:
265
+ if p not in record.fs_ro:
266
+ record.fs_ro.append(p)
267
+ elif action == "rw":
268
+ for p in args.paths:
269
+ if p not in record.fs_rw:
270
+ record.fs_rw.append(p)
271
+ elif action == "scratch":
272
+ for p in args.paths:
273
+ if p not in record.fs_scratch:
274
+ record.fs_scratch.append(p)
275
+
276
+ record.save_json(meta_file)
277
+
278
+ if record.exposed_bins:
279
+ with bin_lock(settings=settings):
280
+ apply_policy(record, settings.bin_dir)
263
281
 
264
282
  _print_fs_policy(record, logger)
265
283
  return 0
@@ -238,14 +238,14 @@ def _resolve_runtime(choice: str | None) -> tuple[str | None, bool | None]:
238
238
 
239
239
 
240
240
  def _check_setup_file_gate(args: argparse.Namespace, logger) -> int | None:
241
- """Guard: --setup-file is gated behind IXT_ENABLE_SETUP_TOML (A/B test v0.1.0)."""
241
+ """Guard the experimental author contract behind IXT_ENABLE_SETUP_TOML."""
242
242
  if not getattr(args, "setup", None):
243
243
  return None
244
244
  from ixt.config.flags import SETUP_TOML_ENV, is_setup_toml_enabled
245
245
 
246
246
  if is_setup_toml_enabled():
247
247
  return None
248
- logger.error("ixt.setup.toml support is disabled in v0.1.0 (A/B test).")
248
+ logger.error("ixt.setup.toml support is disabled by default while experimental.")
249
249
  logger.error(f" {dim(f'Set {SETUP_TOML_ENV}=1 to enable --setup-file and remote fetch.')}")
250
250
  return 1
251
251
 
@@ -497,6 +497,9 @@ def _cmd_uninstall_one(args: argparse.Namespace, logger, settings, *, dry_run: b
497
497
  logger.error(f"Tool '{args.target}' is not installed")
498
498
  logger.error(f" {dim('Hint: run ixt tool list to see installed tools')}")
499
499
  return 1
500
+ except ValueError as exc:
501
+ logger.error(str(exc))
502
+ return 1
500
503
 
501
504
  logger.info(f" {red('-')} {bold(display)}")
502
505
 
@@ -51,7 +51,7 @@ def _cmd_lock_check() -> int:
51
51
  return _log_lock_check_error(exc)
52
52
 
53
53
  if result.ok:
54
- logger.info(f"{result.path} is up to date")
54
+ logger.info(f"{result.path} matches ixt.toml")
55
55
  return 0
56
56
 
57
57
  logger.error(f"{result.path} is out of date")
@@ -69,7 +69,7 @@ def _cmd_lock_diff() -> int:
69
69
  return _log_lock_check_error(exc)
70
70
 
71
71
  if result.ok:
72
- data("ixt.lock is up to date")
72
+ data("ixt.lock matches ixt.toml")
73
73
  return 0
74
74
 
75
75
  data("ixt.lock differs from ixt.toml:")
@@ -19,11 +19,10 @@ fresh release API call.
19
19
  from __future__ import annotations
20
20
 
21
21
  import json
22
- import os
23
- import tempfile
24
22
  from pathlib import Path
25
23
 
26
24
  from ixt.config.settings import get_ixt_metadata_dir
25
+ from ixt.libs.atomic import atomic_write_text
27
26
 
28
27
  CACHE_FILENAME = "asset_patterns.json"
29
28
 
@@ -52,16 +51,7 @@ def _load() -> dict[str, str]:
52
51
 
53
52
  def _atomic_write(data: dict[str, str]) -> None:
54
53
  path = _cache_path()
55
- path.parent.mkdir(parents=True, exist_ok=True)
56
- fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
57
- try:
58
- with os.fdopen(fd, "w", encoding="utf-8") as f:
59
- json.dump(data, f, indent=2, sort_keys=True)
60
- f.write("\n")
61
- os.replace(tmp, path)
62
- except Exception:
63
- Path(tmp).unlink(missing_ok=True)
64
- raise
54
+ atomic_write_text(path, json.dumps(data, indent=2, sort_keys=True) + "\n")
65
55
 
66
56
 
67
57
  def get(owner: str, repo: str) -> str | None:
@@ -8,6 +8,8 @@ import stat
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING
10
10
 
11
+ from ixt.core.pathlink import exposed_target_path
12
+
11
13
  if TYPE_CHECKING:
12
14
  from ixt.config.models import ToolRecord
13
15
 
@@ -322,8 +324,11 @@ def _needs_wrapper(record: ToolRecord) -> bool:
322
324
  def apply_policy(record: ToolRecord, bin_dir: Path) -> None:
323
325
  """Replace symlinks with wrappers (or back) for all exposed bins of *record*."""
324
326
  use_wrapper = _needs_wrapper(record)
325
- for exposed_name, real_path in record.exposed_bins.items():
326
- target = bin_dir / exposed_name
327
+ targets = [
328
+ (exposed_name, real_path, exposed_target_path(bin_dir, exposed_name))
329
+ for exposed_name, real_path in record.exposed_bins.items()
330
+ ]
331
+ for exposed_name, real_path, target in targets:
327
332
  if target.exists() or target.is_symlink():
328
333
  target.unlink()
329
334
  if use_wrapper: