ixt-cli 0.8.0__py3-none-any.whl

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 (84) hide show
  1. ixt/__init__.py +8 -0
  2. ixt/__main__.py +8 -0
  3. ixt/backends/__init__.py +1 -0
  4. ixt/backends/binary.py +935 -0
  5. ixt/backends/binary_resolver.py +307 -0
  6. ixt/backends/node.py +490 -0
  7. ixt/backends/python.py +234 -0
  8. ixt/cli/__init__.py +31 -0
  9. ixt/cli/argparse_completion.py +557 -0
  10. ixt/cli/cmd_apply.py +404 -0
  11. ixt/cli/cmd_cache.py +86 -0
  12. ixt/cli/cmd_config.py +295 -0
  13. ixt/cli/cmd_info.py +116 -0
  14. ixt/cli/cmd_install.py +508 -0
  15. ixt/cli/cmd_misc.py +261 -0
  16. ixt/cli/cmd_registry.py +35 -0
  17. ixt/cli/cmd_upgrade.py +336 -0
  18. ixt/cli/commands.py +70 -0
  19. ixt/cli/parser.py +555 -0
  20. ixt/cli/render.py +85 -0
  21. ixt/config/__init__.py +5 -0
  22. ixt/config/asset_index.py +305 -0
  23. ixt/config/asset_pattern_cache.py +87 -0
  24. ixt/config/env_policy.py +340 -0
  25. ixt/config/flags.py +29 -0
  26. ixt/config/fs_policy.py +17 -0
  27. ixt/config/heuristics.py +465 -0
  28. ixt/config/models.py +176 -0
  29. ixt/config/registry.py +145 -0
  30. ixt/config/settings.py +173 -0
  31. ixt/config/setup_toml.py +179 -0
  32. ixt/config/toml.py +416 -0
  33. ixt/core/__init__.py +16 -0
  34. ixt/core/apply.py +564 -0
  35. ixt/core/apply_actions.py +106 -0
  36. ixt/core/backend.py +187 -0
  37. ixt/core/bootstrap.py +410 -0
  38. ixt/core/cache.py +332 -0
  39. ixt/core/discover.py +150 -0
  40. ixt/core/doctor.py +591 -0
  41. ixt/core/export.py +419 -0
  42. ixt/core/expose.py +350 -0
  43. ixt/core/extract.py +261 -0
  44. ixt/core/hooks.py +182 -0
  45. ixt/core/identity.py +148 -0
  46. ixt/core/inject.py +143 -0
  47. ixt/core/install.py +509 -0
  48. ixt/core/install_local.py +229 -0
  49. ixt/core/locks.py +54 -0
  50. ixt/core/pathlink.py +86 -0
  51. ixt/core/resolution_stats.py +191 -0
  52. ixt/core/resolve.py +150 -0
  53. ixt/core/resolve_cache.py +185 -0
  54. ixt/core/runtimes.py +192 -0
  55. ixt/core/save.py +237 -0
  56. ixt/core/setup_completions.py +11 -0
  57. ixt/core/setup_path.py +368 -0
  58. ixt/core/upgrade.py +596 -0
  59. ixt/data/__init__.py +10 -0
  60. ixt/data/asset_index.json +574 -0
  61. ixt/data/heuristics.toml +98 -0
  62. ixt/data/registry.toml +71 -0
  63. ixt/libs/__init__.py +3 -0
  64. ixt/libs/constants.py +4 -0
  65. ixt/libs/fmt.py +108 -0
  66. ixt/libs/logger.py +109 -0
  67. ixt/libs/output.py +25 -0
  68. ixt/libs/req_spec.py +115 -0
  69. ixt/libs/semver.py +149 -0
  70. ixt/libs/shell.py +126 -0
  71. ixt/libs/style.py +238 -0
  72. ixt/net/__init__.py +1 -0
  73. ixt/net/github_api.py +158 -0
  74. ixt/net/gitlab_api.py +149 -0
  75. ixt/net/http.py +194 -0
  76. ixt/net/npm.py +24 -0
  77. ixt/net/pypi.py +26 -0
  78. ixt/net/source.py +163 -0
  79. ixt/platform/__init__.py +131 -0
  80. ixt/platform/win.py +68 -0
  81. ixt_cli-0.8.0.dist-info/METADATA +294 -0
  82. ixt_cli-0.8.0.dist-info/RECORD +84 -0
  83. ixt_cli-0.8.0.dist-info/WHEEL +4 -0
  84. ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/cli/cmd_misc.py ADDED
@@ -0,0 +1,261 @@
1
+ """Standalone utility subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from ixt.config.models import ToolRecord
11
+ from ixt.config.settings import get_settings
12
+ from ixt.core.install import resolve_tool_arg
13
+ from ixt.libs.logger import get_logger
14
+ from ixt.libs.style import bold, cyan, dim, green, yellow
15
+ from ixt.platform import get_platform_info
16
+
17
+
18
+ def cmd_setup_path(args: argparse.Namespace) -> int:
19
+ from ixt.core.setup_path import setup_path
20
+
21
+ logger = get_logger("setup-path")
22
+ result = setup_path(check_only=args.check, shell=getattr(args, "shell", None))
23
+ if result.warning:
24
+ logger.warn(result.warning)
25
+ logger.info(result.message)
26
+ if result.snippet:
27
+ logger.info("")
28
+ for line in result.snippet.rstrip("\n").splitlines():
29
+ logger.info(f" {line}")
30
+ return 0 if result.status in ("ok", "ok_restart", "added", "migrated") else 1
31
+
32
+
33
+ def cmd_setup_completions(args: argparse.Namespace) -> int:
34
+ from ixt.core.setup_completions import generate_completion
35
+
36
+ script = generate_completion(args.shell)
37
+ sys.stdout.write(script)
38
+ if not script.endswith("\n"):
39
+ sys.stdout.write("\n")
40
+ return 0
41
+
42
+
43
+ def cmd_runtime_upgrade(args: argparse.Namespace) -> int:
44
+ from ixt.core.bootstrap import BootstrapError, RuntimeName, upgrade_runtime
45
+
46
+ logger = get_logger("runtime")
47
+ target = getattr(args, "name", None)
48
+ if not target:
49
+ logger.error("Specify a runtime to upgrade (e.g. 'ixt runtime upgrade uv')")
50
+ return 1
51
+
52
+ names = ("uv", "bun") if target == "all" else (target,)
53
+ ok = True
54
+ for name in names:
55
+ try:
56
+ path = upgrade_runtime(cast(RuntimeName, name))
57
+ except BootstrapError as exc:
58
+ logger.error(f"Failed to upgrade {name}: {exc}")
59
+ ok = False
60
+ continue
61
+ logger.info(f" {green('ok')} {name} upgraded at {dim(path)}")
62
+ return 0 if ok else 1
63
+
64
+
65
+ def cmd_setup(args: argparse.Namespace) -> int:
66
+ """Dispatch ``ixt setup <subcommand>``."""
67
+ sub = getattr(args, "setup_command", None)
68
+ if sub == "path":
69
+ return cmd_setup_path(args)
70
+ if sub == "completions":
71
+ return cmd_setup_completions(args)
72
+ logger = get_logger("setup")
73
+ logger.error("Specify a setup subcommand (e.g. 'ixt setup path')")
74
+ return 1
75
+
76
+
77
+ def cmd_runtimes(args: argparse.Namespace) -> int:
78
+ """Dispatch ``ixt runtime <subcommand>``."""
79
+ action = getattr(args, "runtimes_action", None)
80
+ if action == "info":
81
+ return _cmd_runtimes_info(args)
82
+ if action == "prune":
83
+ return _cmd_runtimes_prune(args)
84
+ if action == "upgrade":
85
+ return cmd_runtime_upgrade(args)
86
+
87
+ logger = get_logger("runtime")
88
+ logger.error("Specify a runtime subcommand (info, prune, upgrade)")
89
+ return 1
90
+
91
+
92
+ def _cmd_runtimes_info(args: argparse.Namespace) -> int:
93
+ from ixt.core.cache import humanize_size
94
+ from ixt.core.runtimes import runtime_statuses
95
+
96
+ _ = args
97
+ logger = get_logger("runtime")
98
+ settings = get_settings()
99
+
100
+ logger.info(f" {bold('ixt runtime')}")
101
+ logger.info(f" Root: {cyan(str(settings.runtimes_dir))}")
102
+ logger.info("")
103
+ for status in runtime_statuses(settings=settings):
104
+ state = green("present") if status.present else dim("missing")
105
+ use = yellow("used") if status.used else dim("unused")
106
+ version = f" {dim(status.version)}" if status.version else ""
107
+ logger.info(
108
+ f" {status.name:3s} {state:10s} {use:8s} "
109
+ f"{humanize_size(status.bytes):>8s} {dim(str(status.path))}{version}"
110
+ )
111
+ if status.used_by:
112
+ logger.info(f" {dim('used by: ' + ', '.join(status.used_by))}")
113
+ elif status.present:
114
+ logger.info(f" {dim('prunable: ' + status.prune_reason)}")
115
+ return 0
116
+
117
+
118
+ def _cmd_runtimes_prune(args: argparse.Namespace) -> int:
119
+ from ixt.core.cache import humanize_size
120
+ from ixt.core.runtimes import prune_runtimes
121
+
122
+ logger = get_logger("runtime")
123
+ results = prune_runtimes(all_=getattr(args, "all", False))
124
+ removed = [result for result in results if result.removed]
125
+ for result in results:
126
+ if result.removed:
127
+ logger.info(
128
+ f" {green('ok')} removed {result.name}: "
129
+ f"{humanize_size(result.bytes)}, {result.files} "
130
+ f"{'file' if result.files == 1 else 'files'} ({dim(str(result.path))})"
131
+ )
132
+ else:
133
+ logger.info(f" {dim('--')} kept {result.name}: {result.reason}")
134
+ logger.info("")
135
+ logger.info(f" Removed: {bold(str(len(removed)))} runtime(s)")
136
+ return 0
137
+
138
+
139
+ def cmd_doctor(args: argparse.Namespace) -> int:
140
+ from ixt.core.doctor import run_doctor
141
+
142
+ logger = get_logger("doctor")
143
+
144
+ pinfo = get_platform_info()
145
+ report = run_doctor(network=not getattr(args, "no_network", False))
146
+
147
+ logger.info(f" {bold('ixt doctor')}")
148
+ shell = next((c.detail for c in report.checks if c.name == "Shell"), "unknown")
149
+ logger.info(
150
+ f" Platform: {pinfo.os.value}/{pinfo.arch.value} "
151
+ f"Shell {shell} Python {pinfo.python_version}"
152
+ )
153
+ logger.info("")
154
+
155
+ for check in report.checks:
156
+ if check.ok:
157
+ logger.info(f" {green('ok')} {check.name:15s} {dim(check.detail)}")
158
+ else:
159
+ logger.info(f" {yellow('!!')} {check.name:15s} {check.detail}")
160
+ if check.hint:
161
+ logger.info(f" {dim('Hint: ' + check.hint)}")
162
+
163
+ logger.info("")
164
+ if report.ok:
165
+ logger.info(f" {green('All checks passed')}")
166
+ else:
167
+ n = len(report.warnings)
168
+ logger.info(f" {yellow(f'{n} warning(s)')}")
169
+
170
+ return 0
171
+
172
+
173
+ def cmd_environment(args: argparse.Namespace) -> int:
174
+ from ixt.core.cache import humanize_size, storage_sizes
175
+
176
+ logger = get_logger("environment")
177
+ settings = get_settings()
178
+ pinfo = get_platform_info()
179
+
180
+ logger.info(f" {bold('ixt environment')}")
181
+ logger.info(f" Platform: {pinfo.os.value}/{pinfo.arch.value}")
182
+ logger.info(f" Python: {pinfo.python_version}")
183
+ logger.info("")
184
+ logger.info(f" Home: {cyan(str(settings.home))}")
185
+ logger.info(f" Config: {cyan(str(settings.config_dir))}")
186
+ logger.info(f" Installed: {cyan(str(settings.installed_dir))}")
187
+ logger.info(f" Bin dir: {cyan(str(settings.bin_dir))}")
188
+ logger.info(f" Envs dir: {cyan(str(settings.envs_dir))}")
189
+ logger.info(f" Runtimes: {cyan(str(settings.runtimes_dir))}")
190
+ logger.info(f" Cache: {cyan(str(settings.cache_home))}")
191
+ logger.info(f" Downloads: {cyan(str(settings.downloads_dir))}")
192
+ logger.info(f" Metadata: {cyan(str(settings.metadata_dir))}")
193
+
194
+ count = len(settings.iter_installed_metadata())
195
+ logger.info("")
196
+ logger.info(f" Tools: {bold(str(count))} installed")
197
+
198
+ if getattr(args, "sizes", False):
199
+ logger.info("")
200
+ logger.info(" Sizes:")
201
+ for entry in storage_sizes(settings=settings):
202
+ files = "file" if entry.files == 1 else "files"
203
+ logger.info(
204
+ f" {entry.name:10s} {humanize_size(entry.bytes):>8s} {entry.files} {files}"
205
+ )
206
+
207
+ return 0
208
+
209
+
210
+ def _get_installed_env_dir(tool: str, *, logger_name: str) -> Path | None:
211
+ logger = get_logger(logger_name)
212
+ settings = get_settings()
213
+
214
+ try:
215
+ tool_name = resolve_tool_arg(tool, settings=settings)
216
+ except ValueError as e:
217
+ logger.error(str(e))
218
+ return None
219
+
220
+ meta_file = settings.get_tool_metadata_file(tool_name)
221
+
222
+ if not meta_file.exists():
223
+ logger.error(f"Tool '{tool}' is not installed")
224
+ return None
225
+
226
+ record = ToolRecord.load_json(meta_file)
227
+ env_dir = Path(record.env_dir).expanduser()
228
+
229
+ if not env_dir.is_dir():
230
+ logger.error(f"Environment directory missing: {env_dir}")
231
+ return None
232
+
233
+ return env_dir
234
+
235
+
236
+ def cmd_shell(args: argparse.Namespace) -> int:
237
+ import os
238
+ import subprocess # nosemgrep: gitlab.bandit.B404
239
+
240
+ logger = get_logger("shell")
241
+ env_dir = _get_installed_env_dir(args.tool, logger_name="shell")
242
+ if env_dir is None:
243
+ return 1
244
+
245
+ # Launch a subshell in the tool's environment directory
246
+ shell = os.environ.get("SHELL", "/bin/sh")
247
+ logger.info(f" Entering {bold(args.tool)} env ({dim(str(env_dir))})")
248
+ logger.info(f" {dim('Type exit to return')}")
249
+ # Safe: $SHELL is the intended source, exec as list (no shell interpretation),
250
+ # env_dir from stored ToolRecord.
251
+ # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-tainted-env-args.dangerous-subprocess-use-tainted-env-args # noqa: E501
252
+ result = subprocess.run([shell], cwd=env_dir)
253
+ return result.returncode
254
+
255
+
256
+ def cmd_where(args: argparse.Namespace) -> int:
257
+ env_dir = _get_installed_env_dir(args.tool, logger_name="where")
258
+ if env_dir is None:
259
+ return 1
260
+ sys.stdout.write(f"{env_dir.resolve()}\n")
261
+ return 0
@@ -0,0 +1,35 @@
1
+ """Registry inspection subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ixt.config.registry import load_registry
8
+ from ixt.libs.logger import get_logger
9
+ from ixt.libs.output import data
10
+ from ixt.libs.style import bold, dim
11
+
12
+
13
+ def cmd_registry(args: argparse.Namespace) -> int:
14
+ """Dispatch ``ixt registry <subcommand>``."""
15
+ action = getattr(args, "registry_action", None)
16
+ if action == "list":
17
+ return _cmd_registry_list(args)
18
+
19
+ logger = get_logger("registry")
20
+ logger.error("Specify a registry subcommand (list)")
21
+ return 1
22
+
23
+
24
+ def _cmd_registry_list(args: argparse.Namespace) -> int:
25
+ """Print known short-name registry entries."""
26
+ _ = args
27
+ registry = load_registry()
28
+ if not registry:
29
+ data("No registry entries")
30
+ return 0
31
+
32
+ width = max(len(name) for name in registry)
33
+ for name, entry in sorted(registry.items()):
34
+ data(f" {bold(name.ljust(width))} {dim(entry.spec)}")
35
+ return 0
ixt/cli/cmd_upgrade.py ADDED
@@ -0,0 +1,336 @@
1
+ """``ixt tool upgrade`` subcommand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import time
7
+ from contextlib import nullcontext
8
+
9
+ from ixt.cli.parser import normalize_verbosity
10
+ from ixt.cli.render import format_source_tag, source_label_for_record
11
+ from ixt.config.models import ToolRecord
12
+ from ixt.config.settings import get_settings
13
+ from ixt.core.install import display_tool_name, resolve_tool_arg
14
+ from ixt.core.resolution_stats import collect_resolution_stats
15
+ from ixt.libs.logger import get_logger, get_verbosity
16
+ from ixt.libs.semver import compare_versions
17
+ from ixt.libs.style import bold, dim, green, red
18
+
19
+
20
+ def cmd_upgrade(args: argparse.Namespace) -> int:
21
+ normalize_verbosity(args)
22
+ logger = get_logger("upgrade")
23
+ dry_run = getattr(args, "dry_run", False)
24
+ use_cache = not getattr(args, "no_cache", False)
25
+
26
+ if dry_run:
27
+ return _cmd_upgrade_dry_run(args, logger, use_cache=use_cache)
28
+
29
+ from ixt.core.upgrade import (
30
+ ToolLocalInstallError,
31
+ ToolNotInstalledError,
32
+ ToolPinnedError,
33
+ upgrade_all,
34
+ upgrade_tool,
35
+ )
36
+
37
+ if args.upgrade_all:
38
+ settings = get_settings()
39
+ total = len(settings.iter_installed_metadata())
40
+ if not total:
41
+ logger.info("No tools installed")
42
+ return 0
43
+ up_to_date = upgraded = pinned = skipped = errors = 0
44
+ start = time.monotonic()
45
+ logger.info(f"checking {total} tool(s)")
46
+ with _resolution_stats_context(args) as resolution_stats:
47
+ for i, (name, result) in enumerate(
48
+ upgrade_all(settings=settings, use_cache=use_cache), 1
49
+ ):
50
+ prefix = dim(f"[{i}/{total}]")
51
+ if isinstance(result, ToolPinnedError):
52
+ pinned += 1
53
+ logger.info(f"{prefix} {_pinned_msg(result, settings=settings)}")
54
+ elif isinstance(result, ToolLocalInstallError):
55
+ skipped += 1
56
+ display = _display_for_tool_name(name, settings)
57
+ styled_display = bold(display)
58
+ logger.info(
59
+ f"{prefix} {dim('=')} {dim('skipped')} "
60
+ f"{styled_display} {dim('(local install)')}"
61
+ )
62
+ elif isinstance(result, Exception):
63
+ errors += 1
64
+ logger.error(f"{prefix} {red('x')} {red('failed')} {name}: {result}")
65
+ else:
66
+ record, old_version = result
67
+ if old_version and record.version and old_version == record.version:
68
+ up_to_date += 1
69
+ else:
70
+ upgraded += 1
71
+ logger.info(f"{prefix} {_upgrade_msg(record, old_version)}")
72
+ logger.info(
73
+ _upgrade_all_footer(
74
+ up_to_date=up_to_date,
75
+ upgraded=upgraded,
76
+ pinned=pinned,
77
+ skipped=skipped,
78
+ failed=errors,
79
+ elapsed=time.monotonic() - start,
80
+ use_cache=use_cache,
81
+ settings=settings,
82
+ )
83
+ )
84
+ _log_resolution_summary(logger, resolution_stats)
85
+ return 1 if errors else 0
86
+
87
+ if not args.tool:
88
+ logger.error("Specify a tool name or use --all")
89
+ return 1
90
+
91
+ try:
92
+ tool_name = resolve_tool_arg(args.tool)
93
+ except ValueError as e:
94
+ logger.error(str(e))
95
+ return 1
96
+
97
+ try:
98
+ record, old_version = upgrade_tool(tool_name, use_cache=use_cache)
99
+ except ToolNotInstalledError:
100
+ logger.error(f"Tool '{args.tool}' is not installed")
101
+ logger.error(f" {dim('Hint: run ixt tool list to see installed tools')}")
102
+ return 1
103
+ except ToolPinnedError as exc:
104
+ logger.info(_pinned_msg(exc, settings=get_settings()))
105
+ return 0
106
+ except ToolLocalInstallError as exc:
107
+ logger.error(str(exc))
108
+ return 1
109
+ except ValueError as exc:
110
+ logger.error(str(exc))
111
+ return 1
112
+
113
+ logger.info(_upgrade_msg(record, old_version))
114
+ return 0
115
+
116
+
117
+ def _fmt_ttl(seconds: int) -> str:
118
+ if seconds <= 0:
119
+ return "off"
120
+ return f"{seconds // 60}m" if seconds % 60 == 0 else f"{seconds}s"
121
+
122
+
123
+ def _fmt_remaining(seconds: int) -> str:
124
+ return f"~{seconds // 60}m" if seconds >= 60 else "<1m"
125
+
126
+
127
+ def _cache_tail(use_cache: bool, settings) -> str:
128
+ """Footer suffix describing the cache policy (or the --no-cache override)."""
129
+ if not use_cache:
130
+ return f" {dim('· fresh (--no-cache)')}"
131
+ from ixt.core.resolve_cache import min_remaining_ttl, ttl_seconds
132
+
133
+ ttl = ttl_seconds()
134
+ if ttl <= 0:
135
+ return ""
136
+ remaining = min_remaining_ttl(settings)
137
+ note = (
138
+ f"cached, expires in {_fmt_remaining(remaining)}"
139
+ if remaining is not None
140
+ else f"cache TTL {_fmt_ttl(ttl)}"
141
+ )
142
+ return f" {dim(f'· {note} · --no-cache to refresh')}"
143
+
144
+
145
+ def _upgrade_all_footer(
146
+ *,
147
+ up_to_date: int,
148
+ upgraded: int,
149
+ pinned: int,
150
+ skipped: int,
151
+ failed: int,
152
+ elapsed: float,
153
+ use_cache: bool,
154
+ settings,
155
+ ) -> str:
156
+ labels = [
157
+ (up_to_date, "up-to-date"),
158
+ (upgraded, "upgraded"),
159
+ (pinned, "pinned"),
160
+ (skipped, "skipped"),
161
+ (failed, "failed"),
162
+ ]
163
+ parts = [f"{n} {label}" for n, label in labels if n]
164
+ summary = ", ".join(parts) if parts else "nothing to do"
165
+ return f"{summary} {dim(f'in {elapsed:.1f}s')}{_cache_tail(use_cache, settings)}"
166
+
167
+
168
+ def _resolution_stats_context(args: argparse.Namespace | None = None):
169
+ verbosity = max(get_verbosity(), getattr(args, "verbose", 0) if args is not None else 0)
170
+ if verbosity > 0:
171
+ return collect_resolution_stats()
172
+ return nullcontext(None)
173
+
174
+
175
+ def _log_resolution_summary(logger, stats) -> None:
176
+ if stats is None or not stats.has_activity():
177
+ return
178
+ summary = stats.format_summary()
179
+ if summary:
180
+ logger.info(f"resolution: {summary}")
181
+
182
+
183
+ def _record_for_tool_name(name: str, settings) -> ToolRecord | None:
184
+ try:
185
+ return ToolRecord.load_json(settings.get_tool_metadata_file(name))
186
+ except (FileNotFoundError, ValueError):
187
+ return None
188
+
189
+
190
+ def _display_for_tool_name(name: str, settings) -> str:
191
+ record = _record_for_tool_name(name, settings)
192
+ return display_tool_name(record, settings) if record is not None else name
193
+
194
+
195
+ def _source_suffix(record: ToolRecord) -> str:
196
+ tag = format_source_tag(source_label_for_record(record))
197
+ return f" {tag}" if tag else ""
198
+
199
+
200
+ def _pinned_msg(err, *, settings=None) -> str:
201
+ """Format the info line shown when upgrade is skipped due to an == pin."""
202
+ tail = ""
203
+ if err.latest_available:
204
+ tail = f" {dim('latest:')} {err.latest_available}"
205
+ record = _record_for_tool_name(err.tool_name, settings) if settings is not None else None
206
+ display = display_tool_name(record, settings) if record is not None else err.tool_name
207
+ source = _source_suffix(record) if record is not None else ""
208
+ return (
209
+ f"{dim('=')} {dim('pinned')} {bold(display)} "
210
+ f"{dim(err.pinned_version)}{source} {dim('(ixt.toml)')}{tail}"
211
+ )
212
+
213
+
214
+ def _resolve_spec_for_record(record: ToolRecord) -> str:
215
+ """Build the resolver spec from a stored record (strips @tag for binary)."""
216
+ if record.backend == "binary":
217
+ from ixt.net.source import strip_version_suffix
218
+
219
+ return strip_version_suffix(record.spec)
220
+ # Python/Node: registry queries need the underlying package name
221
+ # (``record.name`` is the installed id and may include a slot prefix).
222
+ return record.pkg()
223
+
224
+
225
+ def _dry_run_upgrade_line(record: ToolRecord, new_version: str | None) -> str:
226
+ old = record.version or "unknown"
227
+ source = _source_suffix(record)
228
+ display = display_tool_name(record)
229
+ if new_version is None:
230
+ return f" {red('?')} {red('resolve failed')} {bold(display)} {dim(old)}{source}"
231
+ if compare_versions(old, new_version) == 0:
232
+ return f" {dim('=')} {dim('up-to-date')} {bold(display)} {dim(old)}{source}"
233
+ return (
234
+ f" {green('^')} {dim('would upgrade')} {bold(display)} "
235
+ f"{dim(old)} -> {dim(new_version)}{source}"
236
+ )
237
+
238
+
239
+ def _cmd_upgrade_dry_run(args: argparse.Namespace, logger, *, use_cache: bool = True) -> int:
240
+ from ixt.core.backend import BackendType
241
+ from ixt.core.resolve import resolve_latest_many
242
+ from ixt.core.upgrade import load_config_pins
243
+
244
+ settings = get_settings()
245
+
246
+ if args.upgrade_all:
247
+ meta_files = settings.iter_installed_metadata()
248
+ else:
249
+ if not args.tool:
250
+ logger.error("Specify a tool name or use --all")
251
+ return 1
252
+ try:
253
+ tool_name = resolve_tool_arg(args.tool, settings=settings)
254
+ except ValueError as e:
255
+ logger.error(str(e))
256
+ return 1
257
+ meta_file = settings.get_tool_metadata_file(tool_name)
258
+ if not meta_file.exists():
259
+ logger.error(f"Tool '{args.tool}' is not installed")
260
+ logger.error(f" {dim('Hint: run ixt tool list to see installed tools')}")
261
+ return 1
262
+ meta_files = [meta_file]
263
+
264
+ if not meta_files:
265
+ logger.info(f"{dim('[dry-run]')} no tools installed")
266
+ return 0
267
+
268
+ records = [ToolRecord.load_json(meta_file) for meta_file in meta_files]
269
+ # Parse ixt.toml once for all tools, then resolve every latest in parallel.
270
+ pins = load_config_pins(record.name for record in records)
271
+ invalid: set[str] = set()
272
+ requests: list[tuple[str, BackendType, str]] = []
273
+ for record in records:
274
+ try:
275
+ backend = BackendType(record.backend)
276
+ except ValueError:
277
+ invalid.add(record.name)
278
+ continue
279
+ requests.append((record.name, backend, _resolve_spec_for_record(record)))
280
+ start = time.monotonic()
281
+ with _resolution_stats_context(args) as resolution_stats:
282
+ resolved = resolve_latest_many(requests, use_cache=use_cache, settings=settings)
283
+ elapsed = time.monotonic() - start
284
+
285
+ logger.info(f"{dim('[dry-run]')} would upgrade {len(records)} tool(s):")
286
+ any_failed = False
287
+ for record in records:
288
+ if record.name in invalid:
289
+ any_failed = True
290
+ logger.info(_dry_run_upgrade_line(record, None))
291
+ continue
292
+
293
+ pinned = pins.get(record.name)
294
+ new_version = resolved.get(record.name)
295
+ if new_version is None and pinned is None:
296
+ any_failed = True
297
+
298
+ if pinned is not None:
299
+ logger.info(_dry_run_pinned_line(record, pinned, new_version))
300
+ continue
301
+ logger.info(_dry_run_upgrade_line(record, new_version))
302
+
303
+ if args.upgrade_all:
304
+ logger.info(
305
+ f" {dim(f'checked {len(records)} in {elapsed:.1f}s')}"
306
+ f"{_cache_tail(use_cache, settings)}"
307
+ )
308
+ _log_resolution_summary(logger, resolution_stats)
309
+ if any_failed:
310
+ logger.info(f" {dim('hint: set GITHUB_TOKEN for higher GH rate limits')}")
311
+ return 0
312
+
313
+
314
+ def _dry_run_pinned_line(record: ToolRecord, pinned: str, latest: str | None) -> str:
315
+ source = _source_suffix(record)
316
+ tail = f" {dim('latest:')} {latest}" if latest else ""
317
+ return (
318
+ f" {dim('=')} {dim('pinned')} {bold(display_tool_name(record))} "
319
+ f"{dim(record.version or '?')} {dim(f'(pinned: {pinned})')}{source}{tail}"
320
+ )
321
+
322
+
323
+ def _upgrade_msg(record, old_version: str | None = None) -> str:
324
+ """Format an upgrade result message."""
325
+ ver = record.version or ""
326
+ display = display_tool_name(record)
327
+ source = _source_suffix(record)
328
+ if old_version and ver and old_version == ver:
329
+ return f"{dim('=')} {dim('up-to-date')} {bold(display)} {dim(ver)}{source}"
330
+ if old_version and ver and old_version != ver:
331
+ return (
332
+ f"{green('^')} {dim('upgraded')} {bold(display)} "
333
+ f"{dim(old_version)} -> {dim(ver)}{source}"
334
+ )
335
+ version = f" {dim(ver)}" if ver else ""
336
+ return f"{green('^')} {dim('upgraded')} {bold(display)}{version}{source}"
ixt/cli/commands.py ADDED
@@ -0,0 +1,70 @@
1
+ """Command dispatch — re-exports each ``cmd_*`` from its dedicated module.
2
+
3
+ The actual implementations live in ``cli/cmd_install.py``,
4
+ ``cli/cmd_upgrade.py``, ``cli/cmd_info.py``, ``cli/cmd_config.py``,
5
+ ``cli/cmd_apply.py`` and ``cli/cmd_misc.py``. This module aggregates them
6
+ and exposes the ``COMMANDS`` dispatch table consumed by ``cli.main``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ from ixt.cli.cmd_apply import cmd_apply, cmd_export
14
+ from ixt.cli.cmd_cache import cmd_cache
15
+ from ixt.cli.cmd_config import cmd_config
16
+ from ixt.cli.cmd_info import cmd_info, cmd_list
17
+ from ixt.cli.cmd_install import _log_not_found_hint, cmd_add, cmd_install, cmd_uninstall
18
+ from ixt.cli.cmd_misc import (
19
+ cmd_doctor,
20
+ cmd_environment,
21
+ cmd_runtimes,
22
+ cmd_setup,
23
+ cmd_shell,
24
+ cmd_where,
25
+ )
26
+ from ixt.cli.cmd_registry import cmd_registry
27
+ from ixt.cli.cmd_upgrade import cmd_upgrade
28
+
29
+ __all__ = [
30
+ "COMMANDS",
31
+ "_log_not_found_hint",
32
+ "cmd_add",
33
+ "cmd_apply",
34
+ "cmd_cache",
35
+ "cmd_config",
36
+ "cmd_doctor",
37
+ "cmd_environment",
38
+ "cmd_export",
39
+ "cmd_info",
40
+ "cmd_install",
41
+ "cmd_list",
42
+ "cmd_registry",
43
+ "cmd_runtimes",
44
+ "cmd_setup",
45
+ "cmd_shell",
46
+ "cmd_uninstall",
47
+ "cmd_upgrade",
48
+ "cmd_where",
49
+ ]
50
+
51
+
52
+ COMMANDS: dict[str, Callable] = {
53
+ "add": cmd_add,
54
+ "install": cmd_install,
55
+ "uninstall": cmd_uninstall,
56
+ "upgrade": cmd_upgrade,
57
+ "list": cmd_list,
58
+ "info": cmd_info,
59
+ "config": cmd_config,
60
+ "shell": cmd_shell,
61
+ "where": cmd_where,
62
+ "apply": cmd_apply,
63
+ "export": cmd_export,
64
+ "cache": cmd_cache,
65
+ "registry": cmd_registry,
66
+ "runtime": cmd_runtimes,
67
+ "setup": cmd_setup,
68
+ "environment": cmd_environment,
69
+ "doctor": cmd_doctor,
70
+ }