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.
- ixt/__init__.py +8 -0
- ixt/__main__.py +8 -0
- ixt/backends/__init__.py +1 -0
- ixt/backends/binary.py +935 -0
- ixt/backends/binary_resolver.py +307 -0
- ixt/backends/node.py +490 -0
- ixt/backends/python.py +234 -0
- ixt/cli/__init__.py +31 -0
- ixt/cli/argparse_completion.py +557 -0
- ixt/cli/cmd_apply.py +404 -0
- ixt/cli/cmd_cache.py +86 -0
- ixt/cli/cmd_config.py +295 -0
- ixt/cli/cmd_info.py +116 -0
- ixt/cli/cmd_install.py +508 -0
- ixt/cli/cmd_misc.py +261 -0
- ixt/cli/cmd_registry.py +35 -0
- ixt/cli/cmd_upgrade.py +336 -0
- ixt/cli/commands.py +70 -0
- ixt/cli/parser.py +555 -0
- ixt/cli/render.py +85 -0
- ixt/config/__init__.py +5 -0
- ixt/config/asset_index.py +305 -0
- ixt/config/asset_pattern_cache.py +87 -0
- ixt/config/env_policy.py +340 -0
- ixt/config/flags.py +29 -0
- ixt/config/fs_policy.py +17 -0
- ixt/config/heuristics.py +465 -0
- ixt/config/models.py +176 -0
- ixt/config/registry.py +145 -0
- ixt/config/settings.py +173 -0
- ixt/config/setup_toml.py +179 -0
- ixt/config/toml.py +416 -0
- ixt/core/__init__.py +16 -0
- ixt/core/apply.py +564 -0
- ixt/core/apply_actions.py +106 -0
- ixt/core/backend.py +187 -0
- ixt/core/bootstrap.py +410 -0
- ixt/core/cache.py +332 -0
- ixt/core/discover.py +150 -0
- ixt/core/doctor.py +591 -0
- ixt/core/export.py +419 -0
- ixt/core/expose.py +350 -0
- ixt/core/extract.py +261 -0
- ixt/core/hooks.py +182 -0
- ixt/core/identity.py +148 -0
- ixt/core/inject.py +143 -0
- ixt/core/install.py +509 -0
- ixt/core/install_local.py +229 -0
- ixt/core/locks.py +54 -0
- ixt/core/pathlink.py +86 -0
- ixt/core/resolution_stats.py +191 -0
- ixt/core/resolve.py +150 -0
- ixt/core/resolve_cache.py +185 -0
- ixt/core/runtimes.py +192 -0
- ixt/core/save.py +237 -0
- ixt/core/setup_completions.py +11 -0
- ixt/core/setup_path.py +368 -0
- ixt/core/upgrade.py +596 -0
- ixt/data/__init__.py +10 -0
- ixt/data/asset_index.json +574 -0
- ixt/data/heuristics.toml +98 -0
- ixt/data/registry.toml +71 -0
- ixt/libs/__init__.py +3 -0
- ixt/libs/constants.py +4 -0
- ixt/libs/fmt.py +108 -0
- ixt/libs/logger.py +109 -0
- ixt/libs/output.py +25 -0
- ixt/libs/req_spec.py +115 -0
- ixt/libs/semver.py +149 -0
- ixt/libs/shell.py +126 -0
- ixt/libs/style.py +238 -0
- ixt/net/__init__.py +1 -0
- ixt/net/github_api.py +158 -0
- ixt/net/gitlab_api.py +149 -0
- ixt/net/http.py +194 -0
- ixt/net/npm.py +24 -0
- ixt/net/pypi.py +26 -0
- ixt/net/source.py +163 -0
- ixt/platform/__init__.py +131 -0
- ixt/platform/win.py +68 -0
- ixt_cli-0.8.0.dist-info/METADATA +294 -0
- ixt_cli-0.8.0.dist-info/RECORD +84 -0
- ixt_cli-0.8.0.dist-info/WHEEL +4 -0
- ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/core/save.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Save/remove tool entries in the nearest ixt.toml (--save flag support)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ixt.config.toml import (
|
|
8
|
+
IxtConfig,
|
|
9
|
+
ToolSpec,
|
|
10
|
+
find_config_file,
|
|
11
|
+
load_config,
|
|
12
|
+
parse_config,
|
|
13
|
+
serialize_config,
|
|
14
|
+
)
|
|
15
|
+
from ixt.core.backend import BackendType
|
|
16
|
+
from ixt.libs.constants import EXPOSE_MAIN
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_version_from_spec(spec: str, backend_type: BackendType) -> str | None:
|
|
20
|
+
"""Return the version constraint expressed in a user-provided install spec.
|
|
21
|
+
|
|
22
|
+
Preserves the user's intent: an explicit pin stays pinned, a range stays
|
|
23
|
+
a range, and a bare name returns ``None`` (no version). Normalizes bare
|
|
24
|
+
versions (e.g. ``1.2.3``) to ``==1.2.3`` so all backends share the
|
|
25
|
+
``==<ver>`` convention used elsewhere in ixt.toml.
|
|
26
|
+
"""
|
|
27
|
+
from ixt.core.backend import strip_protocol
|
|
28
|
+
|
|
29
|
+
_, clean = strip_protocol(spec)
|
|
30
|
+
|
|
31
|
+
if backend_type == BackendType.BINARY:
|
|
32
|
+
if "@" in clean and not clean.startswith("@"):
|
|
33
|
+
tag = clean.rsplit("@", 1)[1]
|
|
34
|
+
return f"=={tag}" if tag else None
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
if backend_type == BackendType.NODE:
|
|
38
|
+
from ixt.backends.node import parse_npm_spec
|
|
39
|
+
|
|
40
|
+
_, version = parse_npm_spec(clean)
|
|
41
|
+
if not version:
|
|
42
|
+
return None
|
|
43
|
+
if version[0] in "^~<>=":
|
|
44
|
+
return version
|
|
45
|
+
return f"=={version}"
|
|
46
|
+
|
|
47
|
+
# Python / PEP 508
|
|
48
|
+
from ixt.libs.req_spec import parse_requirement
|
|
49
|
+
|
|
50
|
+
return parse_requirement(clean).version_constraint
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tool_entry_key(_record_name: str, record_spec: str, backend_type: BackendType) -> str:
|
|
54
|
+
"""Return the install-spec key to write in ``ixt.toml``."""
|
|
55
|
+
return _canonical_install_spec(record_spec, backend_type)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def tool_install_spec(record_spec: str, backend_type: BackendType) -> str:
|
|
59
|
+
"""Return the explicit install spec for a slotted ``ixt.toml`` entry."""
|
|
60
|
+
return _canonical_install_spec(record_spec, backend_type)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _canonical_install_spec(record_spec: str, backend_type: BackendType) -> str:
|
|
64
|
+
from ixt.core.backend import strip_protocol
|
|
65
|
+
|
|
66
|
+
_forced, clean_spec = strip_protocol(record_spec)
|
|
67
|
+
if backend_type == BackendType.BINARY:
|
|
68
|
+
from ixt.net.source import parse_spec, strip_version_suffix
|
|
69
|
+
|
|
70
|
+
repo_spec = parse_spec(strip_version_suffix(clean_spec))
|
|
71
|
+
if repo_spec is None:
|
|
72
|
+
return clean_spec
|
|
73
|
+
if repo_spec.platform == "github" and repo_spec.host == "github.com":
|
|
74
|
+
return f"@gh:{repo_spec.owner}/{repo_spec.repo}"
|
|
75
|
+
if repo_spec.host == "gitlab.com":
|
|
76
|
+
return f"@gl:{repo_spec.owner}/{repo_spec.repo}"
|
|
77
|
+
return f"@gl:{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}"
|
|
78
|
+
|
|
79
|
+
if backend_type == BackendType.NODE:
|
|
80
|
+
from ixt.backends.node import parse_npm_spec
|
|
81
|
+
|
|
82
|
+
pkg, _version = parse_npm_spec(clean_spec)
|
|
83
|
+
return f"@npm:{pkg}"
|
|
84
|
+
|
|
85
|
+
from ixt.libs.req_spec import parse_requirement
|
|
86
|
+
|
|
87
|
+
pkg = parse_requirement(clean_spec).name
|
|
88
|
+
return f"@pypi:{pkg}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def save_tool(
|
|
92
|
+
name: str,
|
|
93
|
+
*,
|
|
94
|
+
install: str | None = None,
|
|
95
|
+
version: str | None = None,
|
|
96
|
+
expose: list[str] | None = None,
|
|
97
|
+
inject: list[str] | None = None,
|
|
98
|
+
node_shim: bool | None = None,
|
|
99
|
+
runtime: str | None = None,
|
|
100
|
+
asset_pattern: str | None = None,
|
|
101
|
+
config_path: Path | None = None,
|
|
102
|
+
) -> Path:
|
|
103
|
+
"""Add or update a tool entry in ixt.toml. Returns the path written.
|
|
104
|
+
|
|
105
|
+
Preserves existing fields on update — only non-``None`` arguments override
|
|
106
|
+
the current entry, so callers can write partial updates without wiping
|
|
107
|
+
previously-stored inject/expose lists.
|
|
108
|
+
"""
|
|
109
|
+
path, config = _load_or_create(config_path)
|
|
110
|
+
|
|
111
|
+
existing = config.tools.get(name)
|
|
112
|
+
resolved_expose = expose if expose is not None else (existing.expose if existing else None)
|
|
113
|
+
resolved_inject = inject if inject is not None else (existing.inject if existing else None)
|
|
114
|
+
resolved_version = version if version is not None else (existing.version if existing else None)
|
|
115
|
+
resolved_install = install if install is not None else (existing.install if existing else None)
|
|
116
|
+
resolved_node_shim = (
|
|
117
|
+
node_shim if node_shim is not None else (existing.node_shim if existing else None)
|
|
118
|
+
)
|
|
119
|
+
resolved_runtime = runtime if runtime is not None else (existing.runtime if existing else None)
|
|
120
|
+
resolved_asset_pattern = (
|
|
121
|
+
asset_pattern
|
|
122
|
+
if asset_pattern is not None
|
|
123
|
+
else (existing.asset_pattern if existing else None)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
spec = ToolSpec(
|
|
127
|
+
name=name,
|
|
128
|
+
install=resolved_install,
|
|
129
|
+
version=resolved_version,
|
|
130
|
+
expose=resolved_expose if resolved_expose is not None else [EXPOSE_MAIN],
|
|
131
|
+
inject=resolved_inject if resolved_inject is not None else [],
|
|
132
|
+
node_shim=resolved_node_shim,
|
|
133
|
+
runtime=resolved_runtime,
|
|
134
|
+
asset_pattern=resolved_asset_pattern,
|
|
135
|
+
)
|
|
136
|
+
config.tools[name] = spec
|
|
137
|
+
|
|
138
|
+
path.write_text(serialize_config(config), encoding="utf-8")
|
|
139
|
+
return path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def save_inject(
|
|
143
|
+
tool_name: str,
|
|
144
|
+
package: str,
|
|
145
|
+
*,
|
|
146
|
+
config_path: Path | None = None,
|
|
147
|
+
) -> Path | None:
|
|
148
|
+
"""Append *package* to ``tools.<tool_name>.inject`` in ixt.toml.
|
|
149
|
+
|
|
150
|
+
Returns the path written, or None if no ixt.toml exists. Does nothing if
|
|
151
|
+
the package is already listed.
|
|
152
|
+
"""
|
|
153
|
+
path = config_path or find_config_file()
|
|
154
|
+
if path is None or not path.is_file():
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
config = load_config(path)
|
|
158
|
+
spec = config.tools.get(tool_name)
|
|
159
|
+
if spec is None:
|
|
160
|
+
return None
|
|
161
|
+
if package in spec.inject:
|
|
162
|
+
return path
|
|
163
|
+
|
|
164
|
+
spec.inject = [*spec.inject, package]
|
|
165
|
+
path.write_text(serialize_config(config), encoding="utf-8")
|
|
166
|
+
return path
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def remove_inject(
|
|
170
|
+
tool_name: str,
|
|
171
|
+
package: str,
|
|
172
|
+
*,
|
|
173
|
+
config_path: Path | None = None,
|
|
174
|
+
) -> Path | None:
|
|
175
|
+
"""Remove *package* from ``tools.<tool_name>.inject`` in ixt.toml."""
|
|
176
|
+
path = config_path or find_config_file()
|
|
177
|
+
if path is None or not path.is_file():
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
config = load_config(path)
|
|
181
|
+
spec = config.tools.get(tool_name)
|
|
182
|
+
if spec is None or package not in spec.inject:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
spec.inject = [p for p in spec.inject if p != package]
|
|
186
|
+
path.write_text(serialize_config(config), encoding="utf-8")
|
|
187
|
+
return path
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def remove_tool(
|
|
191
|
+
name: str,
|
|
192
|
+
*,
|
|
193
|
+
config_path: Path | None = None,
|
|
194
|
+
) -> Path | None:
|
|
195
|
+
"""Remove a tool entry from ixt.toml. Returns the path written, or None if not found."""
|
|
196
|
+
path = config_path or find_config_file()
|
|
197
|
+
if path is None:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
config = load_config(path)
|
|
201
|
+
key = _find_tool_key(config, name)
|
|
202
|
+
if key is None:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
del config.tools[key]
|
|
206
|
+
path.write_text(serialize_config(config), encoding="utf-8")
|
|
207
|
+
return path
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _load_or_create(config_path: Path | None) -> tuple[Path, IxtConfig]:
|
|
211
|
+
"""Load existing config or create a new empty one at cwd/ixt.toml."""
|
|
212
|
+
path = config_path or find_config_file()
|
|
213
|
+
if path is not None and path.is_file():
|
|
214
|
+
return path, load_config(path)
|
|
215
|
+
|
|
216
|
+
# Use explicit path or default to cwd/ixt.toml
|
|
217
|
+
path = path or (Path.cwd() / "ixt.toml")
|
|
218
|
+
return path, parse_config({}, source_path=path)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _find_tool_key(config: IxtConfig, selector: str) -> str | None:
|
|
222
|
+
if selector in config.tools:
|
|
223
|
+
return selector
|
|
224
|
+
try:
|
|
225
|
+
from ixt.config.settings import get_settings
|
|
226
|
+
from ixt.core.apply import _normalize_tool_entry
|
|
227
|
+
except Exception:
|
|
228
|
+
return None
|
|
229
|
+
settings = get_settings()
|
|
230
|
+
for key, spec in config.tools.items():
|
|
231
|
+
try:
|
|
232
|
+
tool_id, _normalized = _normalize_tool_entry(key, spec, settings)
|
|
233
|
+
except Exception:
|
|
234
|
+
continue
|
|
235
|
+
if tool_id == selector:
|
|
236
|
+
return key
|
|
237
|
+
return None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Generate shell completion setup snippets for the ixt CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ixt.cli.argparse_completion import render_completion
|
|
6
|
+
from ixt.cli.parser import build_parser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_completion(shell: str) -> str:
|
|
10
|
+
"""Return the shell script that enables ixt CLI completions."""
|
|
11
|
+
return render_completion(build_parser(), shell)
|
ixt/core/setup_path.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Configure PATH for the ixt bin directory across shells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from ixt.config.settings import Settings, get_settings
|
|
12
|
+
from ixt.platform import is_windows
|
|
13
|
+
|
|
14
|
+
_BEGIN = "# Added by ixt"
|
|
15
|
+
_END = "# /Added by ixt"
|
|
16
|
+
|
|
17
|
+
KNOWN_SHELLS = ("bash", "zsh", "fish", "pwsh")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SetupPathStatus = Literal[
|
|
21
|
+
"ok", # bin_dir already in current PATH env var
|
|
22
|
+
"ok_restart", # already configured in shell file, restart needed
|
|
23
|
+
"added", # new block written
|
|
24
|
+
"migrated", # legacy block removed, new block written
|
|
25
|
+
"not_configured", # check_only, would need to write
|
|
26
|
+
"blocked", # symlink target or unknown shell, snippet returned
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SetupPathResult:
|
|
32
|
+
"""Outcome of a setup_path operation."""
|
|
33
|
+
|
|
34
|
+
status: SetupPathStatus
|
|
35
|
+
config_file: Path | None = None
|
|
36
|
+
message: str = ""
|
|
37
|
+
warning: str | None = None
|
|
38
|
+
snippet: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _detect_shell() -> str:
|
|
42
|
+
"""Return the user's login shell from ``$SHELL``.
|
|
43
|
+
|
|
44
|
+
One of: bash, zsh, fish, pwsh, unknown.
|
|
45
|
+
"""
|
|
46
|
+
shell_path = os.environ.get("SHELL", "")
|
|
47
|
+
name = Path(shell_path).name if shell_path else ""
|
|
48
|
+
if name in KNOWN_SHELLS:
|
|
49
|
+
return name
|
|
50
|
+
if name in ("powershell", "powershell.exe"):
|
|
51
|
+
return "pwsh"
|
|
52
|
+
return "unknown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _block(shell: str, bin_dir: Path) -> str:
|
|
56
|
+
"""Build the multi-line PATH block for *shell* targeting *bin_dir*."""
|
|
57
|
+
if shell in ("bash", "zsh"):
|
|
58
|
+
return f'{_BEGIN}\nif [ -d "{bin_dir}" ]; then\n PATH="{bin_dir}:$PATH"\nfi\n{_END}\n'
|
|
59
|
+
if shell == "fish":
|
|
60
|
+
return (
|
|
61
|
+
f'{_BEGIN}\nif test -d "{bin_dir}"\n set -gx PATH "{bin_dir}" $PATH\nend\n{_END}\n'
|
|
62
|
+
)
|
|
63
|
+
if shell == "pwsh":
|
|
64
|
+
sep = ";" if is_windows() else ":"
|
|
65
|
+
return (
|
|
66
|
+
f"{_BEGIN}\n"
|
|
67
|
+
f'if (Test-Path -Path "{bin_dir}" -PathType Container) {{\n'
|
|
68
|
+
f' $env:PATH = "{bin_dir}{sep}" + $env:PATH\n'
|
|
69
|
+
f"}}\n"
|
|
70
|
+
f"{_END}\n"
|
|
71
|
+
)
|
|
72
|
+
raise ValueError(f"unsupported shell: {shell}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _target(shell: str) -> Path:
|
|
76
|
+
"""Where the new block is written for *shell*."""
|
|
77
|
+
home = Path.home()
|
|
78
|
+
if shell == "zsh":
|
|
79
|
+
return home / ".zshenv"
|
|
80
|
+
if shell == "bash":
|
|
81
|
+
return home / ".bashrc"
|
|
82
|
+
if shell == "fish":
|
|
83
|
+
return home / ".config" / "fish" / "conf.d" / "ixt.fish"
|
|
84
|
+
if shell == "pwsh":
|
|
85
|
+
if is_windows():
|
|
86
|
+
return home / "Documents" / "PowerShell" / "profile.ps1"
|
|
87
|
+
return home / ".config" / "powershell" / "profile.ps1"
|
|
88
|
+
raise ValueError(f"unsupported shell: {shell}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _legacy(shell: str) -> Path | None:
|
|
92
|
+
"""Where ixt may have written before the new format. None = no migration."""
|
|
93
|
+
home = Path.home()
|
|
94
|
+
if shell == "zsh":
|
|
95
|
+
return home / ".zshrc"
|
|
96
|
+
if shell == "fish":
|
|
97
|
+
return home / ".config" / "fish" / "config.fish"
|
|
98
|
+
if shell == "bash":
|
|
99
|
+
# Legacy and target are the same file — migration is in-place.
|
|
100
|
+
return home / ".bashrc"
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class _BlockSpan:
|
|
106
|
+
kind: str # "new" or "legacy"
|
|
107
|
+
line_start: int
|
|
108
|
+
line_end: int # exclusive
|
|
109
|
+
lines: list[str] = field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _find_blocks(content: str) -> list[_BlockSpan]:
|
|
113
|
+
"""Return ixt blocks (new + legacy) found in *content*."""
|
|
114
|
+
lines = content.splitlines(keepends=True)
|
|
115
|
+
blocks: list[_BlockSpan] = []
|
|
116
|
+
i = 0
|
|
117
|
+
while i < len(lines):
|
|
118
|
+
if lines[i].rstrip("\n") != _BEGIN:
|
|
119
|
+
i += 1
|
|
120
|
+
continue
|
|
121
|
+
end_idx = -1
|
|
122
|
+
for j in range(i + 1, min(i + 21, len(lines))):
|
|
123
|
+
if lines[j].rstrip("\n") == _END:
|
|
124
|
+
end_idx = j
|
|
125
|
+
break
|
|
126
|
+
if end_idx >= 0:
|
|
127
|
+
blocks.append(_BlockSpan("new", i, end_idx + 1, lines[i : end_idx + 1]))
|
|
128
|
+
i = end_idx + 1
|
|
129
|
+
else:
|
|
130
|
+
tail = i + 2 if i + 1 < len(lines) else i + 1
|
|
131
|
+
blocks.append(_BlockSpan("legacy", i, tail, lines[i:tail]))
|
|
132
|
+
i = tail
|
|
133
|
+
return blocks
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _legacy_export_re(shell: str, bin_dir: Path) -> re.Pattern[str]:
|
|
137
|
+
"""Match the legacy single-line export ixt previously wrote."""
|
|
138
|
+
bin_re = re.escape(str(bin_dir))
|
|
139
|
+
if shell == "fish":
|
|
140
|
+
return re.compile(r'^\s*set -gx PATH \$PATH "' + bin_re + r'"\s*$')
|
|
141
|
+
return re.compile(r'^\s*export PATH="\$PATH:' + bin_re + r'"\s*$')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _strip_comments(content: str) -> str:
|
|
145
|
+
return "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _heuristic_has_bin(content: str, bin_dir: Path) -> bool:
|
|
149
|
+
"""Detect a non-comment line referencing *bin_dir* as a path component."""
|
|
150
|
+
bin_re = re.compile(r"(?<![\w./-])" + re.escape(str(bin_dir)) + r"(?![\w./-])")
|
|
151
|
+
return bool(bin_re.search(_strip_comments(content)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _remove_block(path: Path, block: _BlockSpan) -> None:
|
|
155
|
+
"""Strip *block* from *path* in-place."""
|
|
156
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
157
|
+
new = lines[: block.line_start] + lines[block.line_end :]
|
|
158
|
+
path.write_text("".join(new), encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _append_block(path: Path, body: str) -> None:
|
|
162
|
+
"""Append *body* to *path*, separating from prior content with a blank line."""
|
|
163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
165
|
+
with path.open("a", encoding="utf-8") as f:
|
|
166
|
+
if existing and not existing.endswith("\n"):
|
|
167
|
+
f.write("\n")
|
|
168
|
+
if existing:
|
|
169
|
+
f.write("\n")
|
|
170
|
+
f.write(body)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def setup_path(
|
|
174
|
+
*,
|
|
175
|
+
check_only: bool = False,
|
|
176
|
+
settings: Settings | None = None,
|
|
177
|
+
shell: str | None = None,
|
|
178
|
+
) -> SetupPathResult:
|
|
179
|
+
"""Ensure the ixt bin directory is on PATH.
|
|
180
|
+
|
|
181
|
+
Detects the user's shell from ``$SHELL`` (overridable via *shell*) and
|
|
182
|
+
writes a multi-line block with start/end markers, a directory guard, and
|
|
183
|
+
a prepend so ixt shims shadow same-named binaries elsewhere on PATH.
|
|
184
|
+
|
|
185
|
+
Migrates legacy single-line ixt blocks when found:
|
|
186
|
+
strict match → auto-remove + write new block (status "migrated"),
|
|
187
|
+
divergent or heuristic → leave + warn (status "added", warning set).
|
|
188
|
+
"""
|
|
189
|
+
settings = settings or get_settings()
|
|
190
|
+
bin_dir = settings.bin_dir
|
|
191
|
+
bin_str = str(bin_dir)
|
|
192
|
+
|
|
193
|
+
sep = ";" if is_windows() else ":"
|
|
194
|
+
path_dirs = [p for p in os.environ.get("PATH", "").split(sep) if p]
|
|
195
|
+
path_already_active = False
|
|
196
|
+
if is_windows():
|
|
197
|
+
bin_low = bin_str.lower()
|
|
198
|
+
path_already_active = any(p.lower() == bin_low for p in path_dirs)
|
|
199
|
+
elif bin_str in path_dirs:
|
|
200
|
+
path_already_active = True
|
|
201
|
+
|
|
202
|
+
detected = shell or _detect_shell()
|
|
203
|
+
|
|
204
|
+
if detected == "unknown":
|
|
205
|
+
if is_windows():
|
|
206
|
+
return _setup_path_windows(bin_dir, check_only=check_only)
|
|
207
|
+
if path_already_active:
|
|
208
|
+
return SetupPathResult("ok", message="Already in PATH")
|
|
209
|
+
return _blocked_unknown_shell(bin_dir)
|
|
210
|
+
|
|
211
|
+
if detected not in KNOWN_SHELLS:
|
|
212
|
+
if path_already_active:
|
|
213
|
+
return SetupPathResult("ok", message="Already in PATH")
|
|
214
|
+
return _blocked_unknown_shell(bin_dir, shell=detected)
|
|
215
|
+
|
|
216
|
+
result = _setup_path_file(detected, bin_dir, check_only=check_only)
|
|
217
|
+
if path_already_active and result.status == "ok_restart":
|
|
218
|
+
result.status = "ok"
|
|
219
|
+
result.message = "Already in PATH"
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _blocked_unknown_shell(bin_dir: Path, shell: str | None = None) -> SetupPathResult:
|
|
224
|
+
"""Return a blocked result with a POSIX snippet to apply manually."""
|
|
225
|
+
msg = (
|
|
226
|
+
f"Unknown shell '{shell}'; PATH not configured automatically"
|
|
227
|
+
if shell
|
|
228
|
+
else "Could not detect shell; PATH not configured automatically"
|
|
229
|
+
)
|
|
230
|
+
return SetupPathResult("blocked", message=msg, snippet=_block("bash", bin_dir))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _setup_path_file(
|
|
234
|
+
shell: str,
|
|
235
|
+
bin_dir: Path,
|
|
236
|
+
*,
|
|
237
|
+
check_only: bool = False,
|
|
238
|
+
) -> SetupPathResult:
|
|
239
|
+
"""Write or update the PATH block in the per-shell config file."""
|
|
240
|
+
target = _target(shell)
|
|
241
|
+
|
|
242
|
+
if target.is_symlink():
|
|
243
|
+
return SetupPathResult(
|
|
244
|
+
"blocked",
|
|
245
|
+
config_file=target,
|
|
246
|
+
message=(
|
|
247
|
+
f"{target} is a symlink — likely managed externally "
|
|
248
|
+
f"(Nix, home-manager, chezmoi, ...). Add this snippet manually:"
|
|
249
|
+
),
|
|
250
|
+
snippet=_block(shell, bin_dir),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
target_content = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
254
|
+
target_blocks = _find_blocks(target_content)
|
|
255
|
+
|
|
256
|
+
for blk in target_blocks:
|
|
257
|
+
if blk.kind == "new" and str(bin_dir) in "".join(blk.lines):
|
|
258
|
+
return SetupPathResult(
|
|
259
|
+
"ok_restart",
|
|
260
|
+
config_file=target,
|
|
261
|
+
message=f"Already in {target}, restart shell to activate",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
target_lines = target_content.splitlines(keepends=True)
|
|
265
|
+
own_ranges = [(b.line_start, b.line_end) for b in target_blocks]
|
|
266
|
+
non_own = "".join(
|
|
267
|
+
line
|
|
268
|
+
for idx, line in enumerate(target_lines)
|
|
269
|
+
if not any(s <= idx < e for s, e in own_ranges)
|
|
270
|
+
)
|
|
271
|
+
if _heuristic_has_bin(non_own, bin_dir):
|
|
272
|
+
return SetupPathResult(
|
|
273
|
+
"ok_restart",
|
|
274
|
+
config_file=target,
|
|
275
|
+
message=f"{bin_dir} already exported from {target}",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
legacy_path = _legacy(shell)
|
|
279
|
+
|
|
280
|
+
legacy_action: tuple[str, _BlockSpan] | None = None
|
|
281
|
+
legacy_warning: str | None = None
|
|
282
|
+
|
|
283
|
+
if legacy_path and legacy_path.exists():
|
|
284
|
+
same_file = legacy_path == target
|
|
285
|
+
legacy_blocks = (
|
|
286
|
+
target_blocks if same_file else _find_blocks(legacy_path.read_text(encoding="utf-8"))
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
export_re = _legacy_export_re(shell, bin_dir)
|
|
290
|
+
for blk in legacy_blocks:
|
|
291
|
+
if blk.kind != "legacy":
|
|
292
|
+
continue
|
|
293
|
+
export_line = blk.lines[1] if len(blk.lines) >= 2 else ""
|
|
294
|
+
if export_re.match(export_line.rstrip("\n")):
|
|
295
|
+
legacy_action = ("strict", blk)
|
|
296
|
+
else:
|
|
297
|
+
legacy_action = ("divergent", blk)
|
|
298
|
+
legacy_warning = (
|
|
299
|
+
f"Found a previous '{_BEGIN}' block in {legacy_path} "
|
|
300
|
+
f"(line {blk.line_start + 1}) that does not match what "
|
|
301
|
+
f"ixt wrote. Leaving as-is — review and remove manually."
|
|
302
|
+
)
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
if legacy_action is None and not same_file:
|
|
306
|
+
legacy_content = legacy_path.read_text(encoding="utf-8")
|
|
307
|
+
if _heuristic_has_bin(legacy_content, bin_dir):
|
|
308
|
+
legacy_warning = (
|
|
309
|
+
f"{legacy_path} already references {bin_dir} but ixt did "
|
|
310
|
+
f"not write that block. Leaving as-is. ixt's block goes "
|
|
311
|
+
f"to {target}."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if check_only:
|
|
315
|
+
return SetupPathResult(
|
|
316
|
+
"not_configured",
|
|
317
|
+
config_file=target,
|
|
318
|
+
message=f"Not configured; would add to {target}",
|
|
319
|
+
warning=legacy_warning,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
migrated = False
|
|
323
|
+
if legacy_action and legacy_action[0] == "strict":
|
|
324
|
+
_, blk = legacy_action
|
|
325
|
+
if legacy_path is None:
|
|
326
|
+
raise RuntimeError("legacy block selected without a legacy path")
|
|
327
|
+
_remove_block(legacy_path, blk)
|
|
328
|
+
migrated = True
|
|
329
|
+
|
|
330
|
+
_append_block(target, _block(shell, bin_dir))
|
|
331
|
+
|
|
332
|
+
if migrated and legacy_path is not None and legacy_path != target:
|
|
333
|
+
message = f"Migrated from {legacy_path} to {target}, restart shell to activate"
|
|
334
|
+
elif migrated:
|
|
335
|
+
message = f"Migrated legacy block in {target} to new format, restart shell"
|
|
336
|
+
else:
|
|
337
|
+
message = f"Added to {target}, restart shell to activate"
|
|
338
|
+
|
|
339
|
+
return SetupPathResult(
|
|
340
|
+
"migrated" if migrated else "added",
|
|
341
|
+
config_file=target,
|
|
342
|
+
message=message,
|
|
343
|
+
warning=legacy_warning,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _setup_path_windows(bin_dir: Path, *, check_only: bool = False) -> SetupPathResult:
|
|
348
|
+
"""Windows registry fallback when no POSIX shell is detected."""
|
|
349
|
+
from ixt.platform.win import add_to_user_path, get_user_path
|
|
350
|
+
|
|
351
|
+
bin_str = str(bin_dir)
|
|
352
|
+
bin_low = bin_str.lower()
|
|
353
|
+
|
|
354
|
+
user_path = get_user_path() or ""
|
|
355
|
+
if any(p.lower() == bin_low for p in user_path.split(";")):
|
|
356
|
+
return SetupPathResult(
|
|
357
|
+
"ok_restart",
|
|
358
|
+
message="Already in user registry PATH, restart shell to activate",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if check_only:
|
|
362
|
+
return SetupPathResult("not_configured", message="Not in PATH")
|
|
363
|
+
|
|
364
|
+
add_to_user_path(bin_str)
|
|
365
|
+
return SetupPathResult(
|
|
366
|
+
"added",
|
|
367
|
+
message="Added to user PATH in registry, restart shell to activate",
|
|
368
|
+
)
|