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/core/hooks.py ADDED
@@ -0,0 +1,182 @@
1
+ """Lifecycle hook execution engine with security validation.
2
+
3
+ Hooks are defined in ``ixt.setup.toml`` and run at two lifecycle points:
4
+
5
+ * ``post_install`` — after binaries are exposed
6
+ * ``pre_uninstall`` — before environment removal
7
+
8
+ Three security locks are enforced:
9
+
10
+ 1. **bin-only**: command must start with ``{bin}``
11
+ 2. **action whitelist**: last positional arg must be in the allowed set
12
+ 3. **precheck** (post_install only): skip ``run`` if ``check`` succeeds with output
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from ixt.config.setup_toml import HookConfig
20
+ from ixt.libs.logger import get_logger
21
+ from ixt.libs.shell import ShellError, shell_run_output
22
+
23
+ log = get_logger(__name__)
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Whitelists
27
+ # ---------------------------------------------------------------------------
28
+
29
+ POST_INSTALL_ACTIONS = frozenset({"init", "setup", "install"})
30
+ PRE_UNINSTALL_ACTIONS = frozenset({"cleanup", "uninstall", "teardown"})
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Template expansion
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def _expand_template(cmd: str, bin_path: str, env_dir: str) -> str:
38
+ """Replace ``{bin}`` and ``{env_dir}`` placeholders."""
39
+ return cmd.replace("{bin}", bin_path).replace("{env_dir}", env_dir)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Validation
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ class HookValidationError(ValueError):
48
+ """Raised when a hook command fails security validation."""
49
+
50
+
51
+ def _split_args(cmd: str) -> list[str]:
52
+ """Split a command string into arguments (simple whitespace split).
53
+
54
+ Respects quoted strings for paths with spaces.
55
+ """
56
+ import shlex
57
+
58
+ return shlex.split(cmd)
59
+
60
+
61
+ def _last_positional_arg(args: list[str]) -> str | None:
62
+ """Return the last non-flag argument (not starting with ``-``)."""
63
+ for arg in reversed(args):
64
+ if not arg.startswith("-"):
65
+ return arg
66
+ return None
67
+
68
+
69
+ def validate_hook(
70
+ cmd: str,
71
+ *,
72
+ bin_path: str,
73
+ allowed_actions: frozenset[str],
74
+ ) -> list[str]:
75
+ """Validate a hook command against the three security locks.
76
+
77
+ Returns the parsed argument list ready for execution.
78
+
79
+ Raises:
80
+ HookValidationError: if any lock fails.
81
+ """
82
+ args = _split_args(cmd)
83
+
84
+ if not args:
85
+ raise HookValidationError("Hook command is empty")
86
+
87
+ # Lock 1: must start with {bin} (already expanded)
88
+ if args[0] != bin_path:
89
+ raise HookValidationError(
90
+ f"Hook command must start with {{bin}} ({bin_path}), got: {args[0]}"
91
+ )
92
+
93
+ # Lock 2: last positional arg must be in the whitelist
94
+ if len(args) < 2:
95
+ raise HookValidationError("Hook command must include an action argument")
96
+
97
+ action = _last_positional_arg(args[1:])
98
+ if action is None:
99
+ raise HookValidationError("Hook command has no positional action argument")
100
+
101
+ if action not in allowed_actions:
102
+ raise HookValidationError(
103
+ f"Action '{action}' not in allowed set: {sorted(allowed_actions)}"
104
+ )
105
+
106
+ return args
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Execution
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ def run_hook(
115
+ hook: HookConfig,
116
+ *,
117
+ bin_path: str,
118
+ env_dir: Path,
119
+ allowed_actions: frozenset[str],
120
+ ) -> bool:
121
+ """Execute a lifecycle hook if defined.
122
+
123
+ Returns True if the hook ran, False if skipped.
124
+
125
+ Raises:
126
+ HookValidationError: if the command fails validation.
127
+ """
128
+ if not hook.run:
129
+ return False
130
+
131
+ env_dir_str = str(env_dir)
132
+
133
+ # Precheck: if check is defined and succeeds with output, skip run.
134
+ if hook.check:
135
+ check_cmd = _expand_template(hook.check, bin_path, env_dir_str)
136
+ check_args = validate_hook(check_cmd, bin_path=bin_path, allowed_actions=allowed_actions)
137
+ try:
138
+ output = shell_run_output(check_args)
139
+ if output.strip():
140
+ log.debug("Hook precheck passed (non-empty output), skipping run")
141
+ return False
142
+ except ShellError:
143
+ # Check failed (non-zero exit) → proceed with run
144
+ log.debug("Hook precheck failed (non-zero exit), proceeding with run")
145
+
146
+ # Execute run
147
+ run_cmd = _expand_template(hook.run, bin_path, env_dir_str)
148
+ run_args = validate_hook(run_cmd, bin_path=bin_path, allowed_actions=allowed_actions)
149
+
150
+ log.info(f"Running hook: {' '.join(run_args)}")
151
+ shell_run_output(run_args)
152
+ return True
153
+
154
+
155
+ def run_post_install(
156
+ hook: HookConfig,
157
+ *,
158
+ bin_path: str,
159
+ env_dir: Path,
160
+ ) -> bool:
161
+ """Run a post-install hook with post_install action whitelist."""
162
+ return run_hook(
163
+ hook,
164
+ bin_path=bin_path,
165
+ env_dir=env_dir,
166
+ allowed_actions=POST_INSTALL_ACTIONS,
167
+ )
168
+
169
+
170
+ def run_pre_uninstall(
171
+ hook: HookConfig,
172
+ *,
173
+ bin_path: str,
174
+ env_dir: Path,
175
+ ) -> bool:
176
+ """Run a pre-uninstall hook with pre_uninstall action whitelist."""
177
+ return run_hook(
178
+ hook,
179
+ bin_path=bin_path,
180
+ env_dir=env_dir,
181
+ allowed_actions=PRE_UNINSTALL_ACTIONS,
182
+ )
ixt/core/identity.py ADDED
@@ -0,0 +1,148 @@
1
+ """Tool identity helpers.
2
+
3
+ User-facing commands normally accept package specs (``ruff``, ``alajmo/mani``).
4
+ The installed environment id is the unique technical reference
5
+ (``mani.alajmo.github``) and can be selected explicitly with ``@id:<id>``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ from ixt.config.models import ToolRecord
14
+ from ixt.core.backend import BackendType, strip_protocol
15
+ from ixt.libs.req_spec import parse_requirement
16
+ from ixt.net.source import parse_spec, strip_version_suffix
17
+
18
+ ID_SELECTOR_PREFIX = "@id:"
19
+ _SLOT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ToolIdentity:
24
+ """The stable identity surfaces for one installed tool."""
25
+
26
+ id: str
27
+ spec: str
28
+ display: str
29
+ backend: str
30
+ pkg: str
31
+
32
+
33
+ def is_id_selector(value: str) -> bool:
34
+ return value.startswith(ID_SELECTOR_PREFIX)
35
+
36
+
37
+ def parse_id_selector(value: str) -> str:
38
+ """Return the exact id from ``@id:<id>``, or raise ValueError."""
39
+ if not is_id_selector(value):
40
+ raise ValueError(f"Not an id selector: {value!r}")
41
+ tool_id = value[len(ID_SELECTOR_PREFIX) :].strip()
42
+ if not tool_id:
43
+ raise ValueError("Empty @id: selector")
44
+ return tool_id
45
+
46
+
47
+ def validate_slot(slot: str | None) -> str | None:
48
+ """Validate a side-by-side install slot.
49
+
50
+ A slot is a single id segment that gets prepended to the generated base id.
51
+ It deliberately excludes dots, slashes, colons, and spaces so id-prefix
52
+ matching remains predictable.
53
+ """
54
+ if slot is None:
55
+ return None
56
+ if not _SLOT_RE.match(slot):
57
+ raise ValueError("Invalid slot: use only letters, digits, '-' or '_'")
58
+ return slot
59
+
60
+
61
+ def apply_slot(base_id: str, slot: str | None) -> str:
62
+ slot = validate_slot(slot)
63
+ return f"{slot}.{base_id}" if slot else base_id
64
+
65
+
66
+ def make_tool_id(bt: BackendType, pkg_name: str, spec: str, *, slot: str | None = None) -> str:
67
+ """Compute the canonical installed tool id from backend, package and spec."""
68
+ _, clean_spec = strip_protocol(spec)
69
+ if bt == BackendType.BINARY:
70
+ repo_spec = parse_spec(clean_spec)
71
+ base_id = (
72
+ f"{repo_spec.repo}.{repo_spec.owner}.{repo_spec.platform}" if repo_spec else pkg_name
73
+ )
74
+ elif bt == BackendType.NODE:
75
+ if clean_spec.startswith("@"):
76
+ scope = clean_spec.lstrip("@").split("/")[0]
77
+ short_pkg = pkg_name.split("/")[-1] if "/" in pkg_name else pkg_name.lstrip("@")
78
+ base_id = f"{short_pkg}.{scope}.npm"
79
+ else:
80
+ base_id = f"{pkg_name}.npm"
81
+ else:
82
+ base_id = f"{pkg_name}.pypi"
83
+ return apply_slot(base_id, slot)
84
+
85
+
86
+ def slot_from_id(tool_id: str, base_id: str) -> str | None:
87
+ """Return the slot prefix if *tool_id* is a slotted variant of *base_id*."""
88
+ if tool_id == base_id:
89
+ return None
90
+ suffix = f".{base_id}"
91
+ if not tool_id.endswith(suffix):
92
+ return None
93
+ slot = tool_id[: -len(suffix)]
94
+ return validate_slot(slot)
95
+
96
+
97
+ def id_prefixes(tool_id: str) -> set[str]:
98
+ """Return segment prefixes for a dotted id, excluding the full id."""
99
+ parts = [part for part in tool_id.split(".") if part]
100
+ return {".".join(parts[:i]) for i in range(1, len(parts))}
101
+
102
+
103
+ def _binary_display_spec(spec: str) -> str:
104
+ _, clean = strip_protocol(spec)
105
+ repo_spec = parse_spec(clean)
106
+ if repo_spec is None:
107
+ return strip_version_suffix(clean)
108
+ if repo_spec.platform == "github" and repo_spec.host == "github.com":
109
+ return f"{repo_spec.owner}/{repo_spec.repo}"
110
+ return f"{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}"
111
+
112
+
113
+ def _node_display_spec(spec: str) -> str:
114
+ from ixt.backends.node import parse_npm_spec
115
+
116
+ name, _version = parse_npm_spec(spec)
117
+ return name
118
+
119
+
120
+ def _python_display_spec(spec: str, fallback: str) -> str:
121
+ try:
122
+ return parse_requirement(spec).name
123
+ except ValueError:
124
+ return fallback
125
+
126
+
127
+ def display_spec_for_record(record: ToolRecord) -> str:
128
+ """Return the normal user-facing selector for an installed record."""
129
+ if record.source == "local":
130
+ return record.spec
131
+ if record.backend == "binary":
132
+ return _binary_display_spec(record.spec)
133
+ if record.backend == "node":
134
+ return _node_display_spec(record.spec)
135
+ if record.backend == "python":
136
+ return _python_display_spec(record.spec, record.pkg())
137
+ return record.pkg()
138
+
139
+
140
+ def identity_for_record(record: ToolRecord) -> ToolIdentity:
141
+ spec = display_spec_for_record(record)
142
+ return ToolIdentity(
143
+ id=record.name,
144
+ spec=spec,
145
+ display=spec,
146
+ backend=record.backend,
147
+ pkg=record.pkg(),
148
+ )
ixt/core/inject.py ADDED
@@ -0,0 +1,143 @@
1
+ """Inject and uninject packages into/from an installed tool's environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ixt.config.models import ToolRecord
8
+ from ixt.config.settings import Settings, get_settings
9
+ from ixt.core.backend import BackendType, get_backend
10
+ from ixt.core.expose import expose_tool, unexpose_tool
11
+ from ixt.libs.req_spec import parse_requirement
12
+
13
+
14
+ class PackageAlreadyInjectedError(ValueError):
15
+ """Raised when trying to inject a package that's already injected."""
16
+
17
+ def __init__(self, package: str, tool_name: str):
18
+ self.package = package
19
+ self.tool_name = tool_name
20
+ super().__init__(f"Package '{package}' is already injected in '{tool_name}'")
21
+
22
+
23
+ class PackageNotInjectedError(ValueError):
24
+ """Raised when trying to uninject a package that isn't injected."""
25
+
26
+ def __init__(self, package: str, tool_name: str):
27
+ self.package = package
28
+ self.tool_name = tool_name
29
+ super().__init__(f"Package '{package}' is not injected in '{tool_name}'")
30
+
31
+
32
+ def inject_packages(
33
+ tool_name: str,
34
+ packages: list[str],
35
+ *,
36
+ settings: Settings | None = None,
37
+ ) -> ToolRecord:
38
+ """Inject additional packages into an installed tool's environment.
39
+
40
+ Validates that none of the packages are already injected, installs them
41
+ into the existing environment, re-exposes binaries, and updates the record.
42
+
43
+ Returns the updated ToolRecord.
44
+ """
45
+ settings = settings or get_settings()
46
+ meta_file = settings.get_tool_metadata_file(tool_name)
47
+
48
+ if not meta_file.exists():
49
+ raise FileNotFoundError(f"Tool '{tool_name}' is not installed")
50
+
51
+ record = ToolRecord.load_json(meta_file)
52
+ bt = BackendType(record.backend)
53
+ if bt == BackendType.BINARY:
54
+ raise ValueError("Binary backend does not support injected packages")
55
+
56
+ # Check for duplicates against existing injected packages.
57
+ existing_names = {parse_requirement(s).name for s in record.injected}
58
+ for spec in packages:
59
+ name = parse_requirement(spec).name
60
+ if name in existing_names:
61
+ raise PackageAlreadyInjectedError(name, tool_name)
62
+
63
+ env_dir = Path(record.env_dir)
64
+ backend = get_backend(bt, settings=settings)
65
+
66
+ backend.install_packages(env_dir, packages)
67
+
68
+ # Update injected list (sorted for deterministic output).
69
+ record.injected = sorted([*record.injected, *packages])
70
+
71
+ # Re-expose to pick up any new binaries from injected packages.
72
+ unexpose_tool(record.exposed_bins, settings.bin_dir)
73
+ result = expose_tool(
74
+ record.pkg(),
75
+ backend,
76
+ env_dir,
77
+ settings.bin_dir,
78
+ record.expose_rules,
79
+ overwrite=True,
80
+ )
81
+ record.exposed_bins = result.linked
82
+
83
+ record.save_json(meta_file)
84
+ return record
85
+
86
+
87
+ def uninject_packages(
88
+ tool_name: str,
89
+ packages: list[str],
90
+ *,
91
+ settings: Settings | None = None,
92
+ ) -> ToolRecord:
93
+ """Remove injected packages from an installed tool's environment.
94
+
95
+ Validates that the packages are currently injected, uninstalls them,
96
+ re-exposes binaries (some may disappear), and updates the record.
97
+
98
+ Returns the updated ToolRecord.
99
+ """
100
+ settings = settings or get_settings()
101
+ meta_file = settings.get_tool_metadata_file(tool_name)
102
+
103
+ if not meta_file.exists():
104
+ raise FileNotFoundError(f"Tool '{tool_name}' is not installed")
105
+
106
+ record = ToolRecord.load_json(meta_file)
107
+
108
+ # Build a map of injected name → spec for validation.
109
+ injected_names: dict[str, str] = {
110
+ parse_requirement(spec).name: spec for spec in record.injected
111
+ }
112
+
113
+ removing_names: set[str] = set()
114
+ for spec in packages:
115
+ name = parse_requirement(spec).name
116
+ if name not in injected_names:
117
+ raise PackageNotInjectedError(name, tool_name)
118
+ removing_names.add(name)
119
+
120
+ env_dir = Path(record.env_dir)
121
+ bt = BackendType(record.backend)
122
+ backend = get_backend(bt, settings=settings)
123
+
124
+ for name in removing_names:
125
+ backend.uninstall_package(env_dir, name)
126
+
127
+ # Remove from injected list (reuse already-parsed names).
128
+ record.injected = [spec for name, spec in injected_names.items() if name not in removing_names]
129
+
130
+ # Re-expose (removed packages may have contributed binaries).
131
+ unexpose_tool(record.exposed_bins, settings.bin_dir)
132
+ result = expose_tool(
133
+ record.pkg(),
134
+ backend,
135
+ env_dir,
136
+ settings.bin_dir,
137
+ record.expose_rules,
138
+ overwrite=True,
139
+ )
140
+ record.exposed_bins = result.linked
141
+
142
+ record.save_json(meta_file)
143
+ return record