@codebend3r/che 1.0.0 → 1.2.0
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.
- package/README.md +65 -25
- package/bin/art.py +123 -0
- package/bin/che.py +209 -568
- package/bin/commands.py +220 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +54 -283
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1188
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +508 -428
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- package/shell/completions/che.fish +34 -50
package/bin/install.py
CHANGED
|
@@ -7,31 +7,31 @@ Run it directly on a fresh machine::
|
|
|
7
7
|
python3 bin/install.py --yes # same, no questions asked
|
|
8
8
|
|
|
9
9
|
or, once the wrappers exist, through the dispatcher: ``che install``,
|
|
10
|
-
``che
|
|
10
|
+
``che update``, ``che uninstall``. The machine facts this reads are in
|
|
11
|
+
``machine.py``, the rc-file edits in ``rcfile.py``, and ``che doctor``, which
|
|
12
|
+
checks the result, in ``doctor.py``.
|
|
11
13
|
|
|
12
|
-
What "installed" means here is deliberately small.
|
|
13
|
-
user's rc file
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
user's dotfiles.
|
|
14
|
+
What "installed" means here is deliberately small. A few lines go into the
|
|
15
|
+
user's rc file between two markers this script owns: where the repo is, which
|
|
16
|
+
python to use, whether to chime, the line that puts the ``che`` shim on PATH,
|
|
17
|
+
and the line that sources the generated wrapper file. Everything else (the
|
|
18
|
+
wrappers themselves, the completions) lives in the repo and is regenerated
|
|
19
|
+
from ``bin/commands.py``, so upgrading never rewrites the user's dotfiles.
|
|
18
20
|
|
|
19
21
|
Unlike the destructive scripts under ``bin/<category>/``, this one does NOT
|
|
20
22
|
default to dry-run: it is the entry point that creates the wrappers, so
|
|
21
23
|
defaulting to "change nothing" would leave a first-time user with nothing
|
|
22
24
|
installed and no obvious reason why. ``--dry-run`` is still there, and every
|
|
23
|
-
file it
|
|
25
|
+
rc file it edits is backed up first.
|
|
24
26
|
"""
|
|
25
27
|
|
|
26
28
|
from __future__ import annotations
|
|
27
29
|
|
|
30
|
+
import argparse
|
|
28
31
|
import json
|
|
29
|
-
import os
|
|
30
|
-
import re
|
|
31
32
|
import shutil
|
|
32
|
-
import subprocess
|
|
33
33
|
import sys
|
|
34
|
-
from dataclasses import dataclass, field
|
|
34
|
+
from dataclasses import dataclass, field, replace
|
|
35
35
|
from datetime import datetime
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
|
|
@@ -40,603 +40,59 @@ from pathlib import Path
|
|
|
40
40
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
41
41
|
|
|
42
42
|
import shellgen
|
|
43
|
-
from
|
|
43
|
+
from art import print_logo
|
|
44
|
+
from commands import REPO, VERSION, all_commands
|
|
45
|
+
from gitutils import has_tracked_changes
|
|
46
|
+
from machine import (
|
|
47
|
+
BREW_FORMULA,
|
|
48
|
+
INSTALL_HINTS,
|
|
49
|
+
MIN_PYTHON_TEXT,
|
|
50
|
+
PYTHON_HINT,
|
|
51
|
+
SHELLS,
|
|
52
|
+
InstallRecord,
|
|
53
|
+
config_dir,
|
|
54
|
+
config_file,
|
|
55
|
+
default_shell_selection,
|
|
56
|
+
find_python,
|
|
57
|
+
is_git_checkout,
|
|
58
|
+
load_record,
|
|
59
|
+
login_shell,
|
|
60
|
+
missing_binaries,
|
|
61
|
+
python_ok,
|
|
62
|
+
python_version,
|
|
63
|
+
save_record,
|
|
64
|
+
shim_on_path,
|
|
65
|
+
shim_path,
|
|
66
|
+
state_dir,
|
|
67
|
+
tildify,
|
|
68
|
+
update_command,
|
|
69
|
+
version_text,
|
|
70
|
+
)
|
|
71
|
+
from rcfile import commit_rc, find_legacy, read_rc, with_block, without_block, without_legacy
|
|
44
72
|
from tui import choose, confirm, is_interactive, palette
|
|
45
|
-
from utils import
|
|
73
|
+
from utils import (
|
|
74
|
+
BoolFlagParser,
|
|
75
|
+
add_bool_flag,
|
|
76
|
+
atomic_write_text,
|
|
77
|
+
build_parser,
|
|
78
|
+
info,
|
|
79
|
+
note,
|
|
80
|
+
run,
|
|
81
|
+
run_cli,
|
|
82
|
+
run_output,
|
|
83
|
+
success,
|
|
84
|
+
warning,
|
|
85
|
+
)
|
|
46
86
|
|
|
47
87
|
__version__ = "1.0.0"
|
|
48
88
|
|
|
49
|
-
MIN_PYTHON = (3, 12)
|
|
50
|
-
|
|
51
|
-
# Binary -> how to get it. Anything absent from here is either part of macOS or
|
|
52
|
-
# has no obvious one-liner, and doctor says so rather than inventing a command.
|
|
53
|
-
INSTALL_HINTS: dict[str, str] = {
|
|
54
|
-
"ffmpeg": "brew install ffmpeg",
|
|
55
|
-
"ffprobe": "brew install ffmpeg",
|
|
56
|
-
"mpv": "brew install mpv",
|
|
57
|
-
"exiftool": "brew install exiftool",
|
|
58
|
-
"gh": "brew install gh",
|
|
59
|
-
"btop": "brew install btop",
|
|
60
|
-
"git": "brew install git",
|
|
61
|
-
"brew": 'bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
# Binaries that ship with macOS: never suggest installing them, just report.
|
|
65
|
-
SYSTEM_BINARIES = frozenset({"diskutil", "osascript", "sfltool"})
|
|
66
|
-
|
|
67
|
-
BREW_FORMULA = {
|
|
68
|
-
"ffprobe": "ffmpeg",
|
|
69
|
-
"ffmpeg": "ffmpeg",
|
|
70
|
-
"mpv": "mpv",
|
|
71
|
-
"exiftool": "exiftool",
|
|
72
|
-
"gh": "gh",
|
|
73
|
-
"btop": "btop",
|
|
74
|
-
"git": "git",
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# ---------------------------------------------------------------------------
|
|
79
|
-
# Locations
|
|
80
|
-
#
|
|
81
|
-
# Every path is resolved from the environment at call time, never at import
|
|
82
|
-
# time, so the tests can point HOME at a temporary directory.
|
|
83
|
-
# ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def home() -> Path:
|
|
87
|
-
return Path(os.environ.get("HOME", str(Path.home())))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def config_dir() -> Path:
|
|
91
|
-
base = os.environ.get("XDG_CONFIG_HOME") or str(home() / ".config")
|
|
92
|
-
return Path(base) / "che"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def config_file() -> Path:
|
|
96
|
-
return config_dir() / "config.json"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def backup_dir() -> Path:
|
|
100
|
-
return config_dir() / "backups"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def state_dir() -> Path:
|
|
104
|
-
base = os.environ.get("XDG_STATE_HOME") or str(home() / ".local" / "state")
|
|
105
|
-
return Path(base) / "che"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def shim_path() -> Path:
|
|
109
|
-
return home() / ".local" / "bin" / "che"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# ---------------------------------------------------------------------------
|
|
113
|
-
# Shells
|
|
114
|
-
# ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@dataclass
|
|
118
|
-
class ShellTarget:
|
|
119
|
-
"""One shell che can install into, and the rc files it would touch."""
|
|
120
|
-
|
|
121
|
-
name: str
|
|
122
|
-
rc_files: list[Path] = field(default_factory=list)
|
|
123
|
-
binary: str | None = None
|
|
124
|
-
is_login_shell: bool = False
|
|
125
|
-
|
|
126
|
-
@property
|
|
127
|
-
def installed(self) -> bool:
|
|
128
|
-
"""Whether the shell itself exists on this machine."""
|
|
129
|
-
return self.binary is not None
|
|
130
|
-
|
|
131
|
-
@property
|
|
132
|
-
def has_rc(self) -> bool:
|
|
133
|
-
return any(path.exists() for path in self.rc_files)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def login_shell() -> str:
|
|
137
|
-
return Path(os.environ.get("SHELL", "")).name
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def _bash_rc_files() -> list[Path]:
|
|
141
|
-
"""Which bash startup files to write.
|
|
142
|
-
|
|
143
|
-
macOS Terminal and iTerm start bash as a *login* shell, which reads
|
|
144
|
-
``.bash_profile`` and never ``.bashrc``; most Linux terminals do the
|
|
145
|
-
opposite. Writing to whichever already exist (and to ``.bashrc`` when
|
|
146
|
-
neither does) covers both without the usual "why isn't my alias there"
|
|
147
|
-
hunt. The block is idempotent, so a ``.bash_profile`` that sources
|
|
148
|
-
``.bashrc`` just defines the same functions twice, harmlessly.
|
|
149
|
-
"""
|
|
150
|
-
candidates = [home() / ".bashrc", home() / ".bash_profile"]
|
|
151
|
-
existing = [path for path in candidates if path.exists()]
|
|
152
|
-
return existing or [home() / ".bashrc"]
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def _zsh_rc_files() -> list[Path]:
|
|
156
|
-
zdotdir = os.environ.get("ZDOTDIR")
|
|
157
|
-
base = Path(zdotdir) if zdotdir else home()
|
|
158
|
-
return [base / ".zshrc"]
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _fish_rc_files() -> list[Path]:
|
|
162
|
-
base = os.environ.get("XDG_CONFIG_HOME") or str(home() / ".config")
|
|
163
|
-
return [Path(base) / "fish" / "config.fish"]
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _sh_rc_files() -> list[Path]:
|
|
167
|
-
return [home() / ".profile"]
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
RC_RESOLVERS = {
|
|
171
|
-
"zsh": _zsh_rc_files,
|
|
172
|
-
"bash": _bash_rc_files,
|
|
173
|
-
"fish": _fish_rc_files,
|
|
174
|
-
"sh": _sh_rc_files,
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
# `sh` reuses the bash wrapper file: it is plain POSIX shell apart from the
|
|
178
|
-
# completion tail, which is guarded by a $BASH_VERSION test.
|
|
179
|
-
WRAPPER_FOR_SHELL = {"zsh": "zsh", "bash": "bash", "fish": "fish", "sh": "bash"}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def detect_shells() -> list[ShellTarget]:
|
|
183
|
-
"""Every supported shell, whether or not it is present on this machine."""
|
|
184
|
-
current = login_shell()
|
|
185
|
-
targets = []
|
|
186
|
-
for name, resolver in RC_RESOLVERS.items():
|
|
187
|
-
targets.append(
|
|
188
|
-
ShellTarget(
|
|
189
|
-
name=name,
|
|
190
|
-
rc_files=resolver(),
|
|
191
|
-
binary=shutil.which(name),
|
|
192
|
-
is_login_shell=(name == current),
|
|
193
|
-
)
|
|
194
|
-
)
|
|
195
|
-
return targets
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def default_shell_selection(targets: list[ShellTarget]) -> list[str]:
|
|
199
|
-
"""What to install into when the user does not say.
|
|
200
|
-
|
|
201
|
-
The login shell always, plus any other shell that is both installed and
|
|
202
|
-
already has an rc file - those are shells the user demonstrably uses.
|
|
203
|
-
``sh``/``.profile`` is opt-in only; writing there affects every POSIX shell
|
|
204
|
-
on the machine and is rarely what someone means.
|
|
205
|
-
"""
|
|
206
|
-
chosen = [target.name for target in targets if target.is_login_shell and target.name != "sh"]
|
|
207
|
-
for target in targets:
|
|
208
|
-
if target.name in ("sh", *chosen):
|
|
209
|
-
continue
|
|
210
|
-
if target.installed and target.has_rc:
|
|
211
|
-
chosen.append(target.name)
|
|
212
|
-
return chosen or ["zsh"]
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
# ---------------------------------------------------------------------------
|
|
216
|
-
# Python interpreter
|
|
217
|
-
# ---------------------------------------------------------------------------
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def python_version(executable: str) -> tuple[int, ...] | None:
|
|
221
|
-
"""Version of another interpreter, or None if it cannot be asked."""
|
|
222
|
-
if executable == sys.executable:
|
|
223
|
-
return sys.version_info[:3]
|
|
224
|
-
try:
|
|
225
|
-
output = run(
|
|
226
|
-
[executable, "-c", "import sys; print('.'.join(str(p) for p in sys.version_info[:3]))"],
|
|
227
|
-
capture=True,
|
|
228
|
-
check=True,
|
|
229
|
-
timeout=10,
|
|
230
|
-
).stdout
|
|
231
|
-
except (OSError, subprocess.SubprocessError):
|
|
232
|
-
return None
|
|
233
|
-
try:
|
|
234
|
-
return tuple(int(part) for part in output.strip().split("."))
|
|
235
|
-
except ValueError:
|
|
236
|
-
return None
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def find_python() -> tuple[str, tuple[int, ...] | None]:
|
|
240
|
-
"""Pick the interpreter the wrappers should use.
|
|
241
|
-
|
|
242
|
-
The interpreter running this script wins when it is new enough - it is the
|
|
243
|
-
one the user just proved works. Otherwise fall back to the newest suitable
|
|
244
|
-
``python3.X`` on PATH. macOS matters here: ``/usr/bin/python3`` is whatever
|
|
245
|
-
the Command Line Tools shipped, often years behind the Homebrew python that
|
|
246
|
-
is also installed.
|
|
247
|
-
"""
|
|
248
|
-
if sys.version_info[:2] >= MIN_PYTHON:
|
|
249
|
-
return sys.executable, sys.version_info[:3]
|
|
250
|
-
|
|
251
|
-
candidates = ["python3.14", "python3.13", "python3.12", "python3"]
|
|
252
|
-
for candidate in candidates:
|
|
253
|
-
resolved = shutil.which(candidate)
|
|
254
|
-
if not resolved:
|
|
255
|
-
continue
|
|
256
|
-
version = python_version(resolved)
|
|
257
|
-
if version and version[:2] >= MIN_PYTHON:
|
|
258
|
-
return resolved, version
|
|
259
|
-
|
|
260
|
-
return sys.executable, sys.version_info[:3]
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
# ---------------------------------------------------------------------------
|
|
264
|
-
# rc-file surgery
|
|
265
|
-
# ---------------------------------------------------------------------------
|
|
266
|
-
|
|
267
|
-
BEGIN = shellgen.BEGIN_MARKER
|
|
268
|
-
END = shellgen.END_MARKER
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def read_rc(path: Path) -> str:
|
|
272
|
-
try:
|
|
273
|
-
return path.read_text(encoding="utf-8")
|
|
274
|
-
except (OSError, UnicodeDecodeError):
|
|
275
|
-
return ""
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def find_block(text: str) -> tuple[int, int] | None:
|
|
279
|
-
"""Character span of the managed block, or None when it is not there."""
|
|
280
|
-
start = text.find(BEGIN)
|
|
281
|
-
if start < 0:
|
|
282
|
-
return None
|
|
283
|
-
end = text.find(END, start)
|
|
284
|
-
if end < 0:
|
|
285
|
-
# A truncated block (someone deleted the end marker): treat everything
|
|
286
|
-
# from the start marker to end of file as ours rather than appending a
|
|
287
|
-
# second copy that would never be found again.
|
|
288
|
-
return start, len(text)
|
|
289
|
-
return start, end + len(END)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def backup_file(path: Path, *, dry_run: bool = False) -> Path | None:
|
|
293
|
-
"""Copy ``path`` into the backup directory before it is edited."""
|
|
294
|
-
if not path.exists():
|
|
295
|
-
return None
|
|
296
|
-
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
297
|
-
target = backup_dir() / f"{path.name.lstrip('.')}.{stamp}.bak"
|
|
298
|
-
if dry_run:
|
|
299
|
-
return target
|
|
300
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
-
shutil.copy2(path, target)
|
|
302
|
-
return target
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def apply_block(path: Path, block: str, *, dry_run: bool = False) -> str:
|
|
306
|
-
"""Insert or replace the managed block in ``path``.
|
|
307
|
-
|
|
308
|
-
Returns one of ``created``, ``updated``, ``unchanged``. The block goes at
|
|
309
|
-
the *end* of the file on a fresh install so that its definitions win over
|
|
310
|
-
anything defined earlier, and stays exactly where it is on an update so a
|
|
311
|
-
user who moved it keeps their placement.
|
|
312
|
-
"""
|
|
313
|
-
original = read_rc(path)
|
|
314
|
-
span = find_block(original)
|
|
315
|
-
|
|
316
|
-
if span is None:
|
|
317
|
-
separator = "" if not original or original.endswith("\n\n") else "\n"
|
|
318
|
-
if original and not original.endswith("\n"):
|
|
319
|
-
separator = "\n\n"
|
|
320
|
-
updated = f"{original}{separator}{block}"
|
|
321
|
-
action = "created"
|
|
322
|
-
else:
|
|
323
|
-
start, end = span
|
|
324
|
-
if original[start:end].rstrip() == block.rstrip():
|
|
325
|
-
return "unchanged"
|
|
326
|
-
updated = original[:start] + block.rstrip("\n") + original[end:]
|
|
327
|
-
action = "updated"
|
|
328
|
-
|
|
329
|
-
if dry_run:
|
|
330
|
-
return action
|
|
331
|
-
|
|
332
|
-
backup_file(path)
|
|
333
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
-
path.write_text(updated, encoding="utf-8")
|
|
335
|
-
return action
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def remove_block(path: Path, *, dry_run: bool = False) -> bool:
|
|
339
|
-
"""Take the managed block back out. Returns whether anything changed."""
|
|
340
|
-
original = read_rc(path)
|
|
341
|
-
span = find_block(original)
|
|
342
|
-
if span is None:
|
|
343
|
-
return False
|
|
344
|
-
if dry_run:
|
|
345
|
-
return True
|
|
346
|
-
|
|
347
|
-
start, end = span
|
|
348
|
-
updated = original[:start].rstrip("\n") + "\n" + original[end:].lstrip("\n")
|
|
349
|
-
backup_file(path)
|
|
350
|
-
path.write_text(updated, encoding="utf-8")
|
|
351
|
-
return True
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
# ---------------------------------------------------------------------------
|
|
355
|
-
# Legacy hand-written wrappers
|
|
356
|
-
# ---------------------------------------------------------------------------
|
|
357
|
-
|
|
358
|
-
LEGACY_VARIABLE = "SHELL_FUNCTIONS_BIN"
|
|
359
|
-
|
|
360
|
-
_ASSIGNMENT = re.compile(rf"^(?:export\s+)?{LEGACY_VARIABLE}=")
|
|
361
|
-
_FUNCTION_HEAD = re.compile(r"^(?:function\s+)?([A-Za-z0-9_.-]+)\s*\(\)\s*\{$")
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
@dataclass(frozen=True)
|
|
365
|
-
class LegacyBlock:
|
|
366
|
-
"""One hand-written wrapper (or the helper behind it) found in an rc file."""
|
|
367
|
-
|
|
368
|
-
start: int
|
|
369
|
-
end: int
|
|
370
|
-
name: str
|
|
371
|
-
body: str
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def find_legacy(text: str) -> list[str]:
|
|
375
|
-
"""Names of hand-written wrappers in ``text`` that che now generates.
|
|
376
|
-
|
|
377
|
-
Before the installer existed, the documented workflow was to copy the
|
|
378
|
-
repo's ``.zshrc`` wrappers into ``~/.zshrc`` by hand, so most machines have
|
|
379
|
-
a few dozen of them sitting there. They are harmless - the managed block is
|
|
380
|
-
appended after, so its definitions win - but they go stale, which is why
|
|
381
|
-
doctor reports them and ``--replace-legacy`` removes them.
|
|
382
|
-
"""
|
|
383
|
-
spans, _ = _legacy_spans(text)
|
|
384
|
-
return [name for _, _, name in spans if name]
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def _function_blocks(lines: list[str], managed: range) -> list[LegacyBlock]:
|
|
388
|
-
"""Every complete ``name() { … }`` outside the managed block."""
|
|
389
|
-
blocks: list[LegacyBlock] = []
|
|
390
|
-
index = 0
|
|
391
|
-
|
|
392
|
-
while index < len(lines):
|
|
393
|
-
if index in managed:
|
|
394
|
-
index += 1
|
|
395
|
-
continue
|
|
396
|
-
|
|
397
|
-
head = _FUNCTION_HEAD.match(lines[index].strip())
|
|
398
|
-
if not head:
|
|
399
|
-
index += 1
|
|
400
|
-
continue
|
|
401
|
-
|
|
402
|
-
depth = 1
|
|
403
|
-
cursor = index + 1
|
|
404
|
-
body: list[str] = []
|
|
405
|
-
while cursor < len(lines) and depth > 0:
|
|
406
|
-
depth += lines[cursor].count("{") - lines[cursor].count("}")
|
|
407
|
-
body.append(lines[cursor])
|
|
408
|
-
cursor += 1
|
|
409
|
-
|
|
410
|
-
if depth == 0:
|
|
411
|
-
blocks.append(LegacyBlock(index, cursor, head.group(1), "\n".join(body)))
|
|
412
|
-
index = cursor
|
|
413
|
-
continue
|
|
414
|
-
|
|
415
|
-
index += 1
|
|
416
|
-
|
|
417
|
-
return blocks
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def _legacy_spans(text: str) -> tuple[list[tuple[int, int, str]], set[str]]:
|
|
421
|
-
"""Line spans holding legacy wrappers, plus the helper names among them.
|
|
422
|
-
|
|
423
|
-
Seeded with everything that mentions ``SHELL_FUNCTIONS_BIN`` directly, then
|
|
424
|
-
grown: a function that calls a legacy *helper* is legacy too. That second
|
|
425
|
-
step is what handles a table-driven rc file, where the wrappers are built
|
|
426
|
-
by an ``_sf`` helper that itself only calls ``_sf_run``.
|
|
427
|
-
|
|
428
|
-
Growth deliberately stops at helpers. A function of the user's own that
|
|
429
|
-
happens to call ``sync-all-branches`` is their automation, not a wrapper,
|
|
430
|
-
and must survive untouched.
|
|
431
|
-
"""
|
|
432
|
-
lines = text.splitlines()
|
|
433
|
-
managed = _managed_line_range(lines)
|
|
434
|
-
blocks = _function_blocks(lines, managed)
|
|
435
|
-
|
|
436
|
-
from commands import wrapper_names
|
|
437
|
-
|
|
438
|
-
generated = set(wrapper_names())
|
|
439
|
-
|
|
440
|
-
marked = {block for block in blocks if LEGACY_VARIABLE in block.body}
|
|
441
|
-
helpers = {block.name for block in marked if block.name not in generated}
|
|
442
|
-
|
|
443
|
-
while True:
|
|
444
|
-
grown = {
|
|
445
|
-
block
|
|
446
|
-
for block in blocks
|
|
447
|
-
if block not in marked and any(helper in block.body for helper in helpers)
|
|
448
|
-
}
|
|
449
|
-
if not grown:
|
|
450
|
-
break
|
|
451
|
-
marked |= grown
|
|
452
|
-
helpers |= {block.name for block in grown if block.name not in generated}
|
|
453
|
-
|
|
454
|
-
spans = [(_extend_upwards(lines, block.start), block.end, block.name) for block in marked]
|
|
455
|
-
spans += [
|
|
456
|
-
(_extend_upwards(lines, index), index + 1, "")
|
|
457
|
-
for index, line in enumerate(lines)
|
|
458
|
-
if index not in managed and _ASSIGNMENT.match(line.strip())
|
|
459
|
-
]
|
|
460
|
-
return sorted(spans), helpers
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def _extend_upwards(lines: list[str], start: int) -> int:
|
|
464
|
-
"""Pull the comment block that documents a wrapper into its span.
|
|
465
|
-
|
|
466
|
-
Every wrapper in the old ``.zshrc`` had a banner or a sentence above it.
|
|
467
|
-
Removing the function but leaving its comment behind produces a file full
|
|
468
|
-
of headings with nothing under them, so the span starts at the first line
|
|
469
|
-
of the attached comment instead. The walk stops at the first line that is
|
|
470
|
-
neither a comment nor the single blank line between a banner and what it
|
|
471
|
-
introduces, so a comment belonging to code that stays is never taken.
|
|
472
|
-
"""
|
|
473
|
-
cursor = start
|
|
474
|
-
if cursor > 0 and not lines[cursor - 1].strip():
|
|
475
|
-
cursor -= 1
|
|
476
|
-
while cursor > 0 and lines[cursor - 1].lstrip().startswith("#"):
|
|
477
|
-
cursor -= 1
|
|
478
|
-
return cursor if lines[cursor].lstrip().startswith("#") else start
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def find_shadowing(text: str) -> list[tuple[str, str]]:
|
|
482
|
-
"""Names in ``text`` that would win over a generated wrapper.
|
|
483
|
-
|
|
484
|
-
Two ways that happens, both silent: an alias of the same name (in zsh and
|
|
485
|
-
bash an alias is resolved before a function, so `alias remove-metadata=...`
|
|
486
|
-
makes the wrapper dead code), and a function redefined *after* the managed
|
|
487
|
-
block, since the last definition wins. Definitions before the block are
|
|
488
|
-
fine, so they are not reported.
|
|
489
|
-
"""
|
|
490
|
-
from commands import wrapper_names
|
|
491
|
-
|
|
492
|
-
names = set(wrapper_names())
|
|
493
|
-
span = find_block(text)
|
|
494
|
-
block_end = span[1] if span else len(text)
|
|
495
|
-
|
|
496
|
-
alias_pattern = re.compile(r"^alias\s+([A-Za-z0-9_.-]+)=")
|
|
497
|
-
function_pattern = re.compile(r"^(?:function\s+)?([A-Za-z0-9_.-]+)\s*(?:\(\))?\s*\{")
|
|
498
|
-
|
|
499
|
-
found: list[tuple[str, str]] = []
|
|
500
|
-
offset = 0
|
|
501
|
-
for line in text.splitlines(keepends=True):
|
|
502
|
-
stripped = line.strip()
|
|
503
|
-
start_of_line, offset = offset, offset + len(line)
|
|
504
|
-
|
|
505
|
-
alias = alias_pattern.match(stripped)
|
|
506
|
-
if alias and alias.group(1) in names:
|
|
507
|
-
found.append((alias.group(1), "alias"))
|
|
508
|
-
continue
|
|
509
|
-
|
|
510
|
-
if start_of_line <= block_end:
|
|
511
|
-
# Defined before or inside our block: ours is sourced later and
|
|
512
|
-
# wins, so there is nothing to report.
|
|
513
|
-
continue
|
|
514
|
-
|
|
515
|
-
definition = function_pattern.match(stripped)
|
|
516
|
-
if definition and definition.group(1) in names:
|
|
517
|
-
found.append((definition.group(1), "redefined after the block"))
|
|
518
|
-
|
|
519
|
-
return found
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
def _managed_line_range(lines: list[str]) -> range:
|
|
523
|
-
"""Line indices covered by the managed block, so stripping never enters it."""
|
|
524
|
-
begin = end = None
|
|
525
|
-
for index, line in enumerate(lines):
|
|
526
|
-
if line.startswith(BEGIN):
|
|
527
|
-
begin = index
|
|
528
|
-
elif line.startswith(END):
|
|
529
|
-
end = index
|
|
530
|
-
if begin is None:
|
|
531
|
-
return range(0, 0)
|
|
532
|
-
return range(begin, (end if end is not None else len(lines) - 1) + 1)
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def _consecutive_groups(indices: list[int]) -> list[list[int]]:
|
|
536
|
-
groups: list[list[int]] = []
|
|
537
|
-
for index in sorted(indices):
|
|
538
|
-
if groups and index == groups[-1][-1] + 1:
|
|
539
|
-
groups[-1].append(index)
|
|
540
|
-
else:
|
|
541
|
-
groups.append([index])
|
|
542
|
-
return groups
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def strip_legacy(path: Path, *, dry_run: bool = False) -> list[str]:
|
|
546
|
-
"""Remove legacy wrappers from ``path``; returns the names removed.
|
|
547
|
-
|
|
548
|
-
Two passes. The first drops the ``SHELL_FUNCTIONS_BIN`` assignment, every
|
|
549
|
-
function that used it, and the helpers those functions were built from,
|
|
550
|
-
along with the comment documenting each. The second drops the lines that
|
|
551
|
-
*call* a removed helper - which is what makes a table-driven ``~/.zshrc``
|
|
552
|
-
safe to clean: removing an ``_sf`` helper while leaving its forty
|
|
553
|
-
``_sf <name> …`` lines behind would turn every new shell into forty
|
|
554
|
-
"command not found" errors.
|
|
555
|
-
|
|
556
|
-
Calls to removed *wrappers* are never touched, only calls to helpers. A
|
|
557
|
-
line of the user's own that runs ``mount-all-drives`` at login is theirs.
|
|
558
|
-
"""
|
|
559
|
-
original = read_rc(path)
|
|
560
|
-
spans, helpers = _legacy_spans(original)
|
|
561
|
-
if not spans:
|
|
562
|
-
return []
|
|
563
|
-
|
|
564
|
-
removed = [name for _, _, name in spans if name]
|
|
565
|
-
lines = original.splitlines()
|
|
566
|
-
managed = _managed_line_range(lines)
|
|
567
|
-
|
|
568
|
-
drop: set[int] = set()
|
|
569
|
-
for span_start, span_end, _ in spans:
|
|
570
|
-
drop.update(range(span_start, span_end))
|
|
571
|
-
|
|
572
|
-
callers = [
|
|
573
|
-
index
|
|
574
|
-
for index, line in enumerate(lines)
|
|
575
|
-
if index not in drop and index not in managed and line.strip().split(" ")[0] in helpers
|
|
576
|
-
]
|
|
577
|
-
for group in _consecutive_groups(callers):
|
|
578
|
-
drop.update(range(_extend_upwards(lines, group[0]), group[-1] + 1))
|
|
579
|
-
|
|
580
|
-
if dry_run:
|
|
581
|
-
return removed
|
|
582
|
-
|
|
583
|
-
kept = [line for index, line in enumerate(lines) if index not in drop]
|
|
584
|
-
|
|
585
|
-
# Collapse the runs of blank lines the removal leaves behind, so the rc
|
|
586
|
-
# file does not end up with a hole in it.
|
|
587
|
-
tidied: list[str] = []
|
|
588
|
-
for line in kept:
|
|
589
|
-
if not line.strip() and tidied and not tidied[-1].strip():
|
|
590
|
-
continue
|
|
591
|
-
tidied.append(line)
|
|
592
|
-
|
|
593
|
-
backup_file(path)
|
|
594
|
-
path.write_text("\n".join(tidied).rstrip("\n") + "\n", encoding="utf-8")
|
|
595
|
-
return removed
|
|
596
|
-
|
|
597
89
|
|
|
598
90
|
# ---------------------------------------------------------------------------
|
|
599
|
-
#
|
|
91
|
+
# The PATH shim and external tools
|
|
600
92
|
# ---------------------------------------------------------------------------
|
|
601
93
|
|
|
602
94
|
|
|
603
|
-
|
|
604
|
-
"""Every generated path mapped to the contents the manifest implies.
|
|
605
|
-
|
|
606
|
-
``shell/`` is generated whole. The README is generated in place: only the
|
|
607
|
-
marked islands are replaced, so its prose survives.
|
|
608
|
-
"""
|
|
609
|
-
expected = dict(shellgen.generated_files())
|
|
610
|
-
|
|
611
|
-
readme = REPO / shellgen.README
|
|
612
|
-
if readme.exists():
|
|
613
|
-
expected[shellgen.README] = shellgen.render_readme(readme.read_text(encoding="utf-8"))
|
|
614
|
-
|
|
615
|
-
return expected
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
def write_generated(*, dry_run: bool = False) -> list[Path]:
|
|
619
|
-
"""Refresh the generated files; returns the ones that changed."""
|
|
620
|
-
changed = []
|
|
621
|
-
for relative, contents in _expected_files().items():
|
|
622
|
-
target = REPO / relative
|
|
623
|
-
if target.exists() and target.read_text(encoding="utf-8") == contents:
|
|
624
|
-
continue
|
|
625
|
-
changed.append(relative)
|
|
626
|
-
if not dry_run:
|
|
627
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
628
|
-
target.write_text(contents, encoding="utf-8")
|
|
629
|
-
return changed
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def check_generated() -> list[Path]:
|
|
633
|
-
"""Generated files that differ from what the manifest would produce."""
|
|
634
|
-
stale = []
|
|
635
|
-
for relative, contents in _expected_files().items():
|
|
636
|
-
target = REPO / relative
|
|
637
|
-
if not target.exists() or target.read_text(encoding="utf-8") != contents:
|
|
638
|
-
stale.append(relative)
|
|
639
|
-
return stale
|
|
95
|
+
SHIM_HEADER = "# Generated by `che install`"
|
|
640
96
|
|
|
641
97
|
|
|
642
98
|
def install_shim(python: str, *, dry_run: bool = False) -> str:
|
|
@@ -644,71 +100,44 @@ def install_shim(python: str, *, dry_run: bool = False) -> str:
|
|
|
644
100
|
|
|
645
101
|
The shell function is what a human types; this shim is what a cron job, a
|
|
646
102
|
Makefile or another script can call, none of which source an rc file.
|
|
103
|
+
|
|
104
|
+
A ``che`` that is a symlink is never written through. With an npm prefix of
|
|
105
|
+
``~/.local``, ``npm install -g`` links it straight to ``bin/che.py``, and
|
|
106
|
+
writing through that link would replace the dispatcher with a shim that
|
|
107
|
+
execs itself. A link to this repo's dispatcher already does the shim's job;
|
|
108
|
+
any other link is replaced, not followed.
|
|
647
109
|
"""
|
|
648
110
|
target = shim_path()
|
|
111
|
+
dispatcher = REPO / "bin" / "che.py"
|
|
649
112
|
script = (
|
|
650
113
|
"#!/bin/sh\n"
|
|
651
|
-
f"
|
|
652
|
-
f'exec {json.dumps(python)} {json.dumps(str(
|
|
114
|
+
f"{SHIM_HEADER} (che {VERSION}). Do not edit.\n"
|
|
115
|
+
f'exec {json.dumps(python)} {json.dumps(str(dispatcher))} "$@"\n'
|
|
653
116
|
)
|
|
654
117
|
|
|
655
|
-
if target.
|
|
118
|
+
if target.is_symlink():
|
|
119
|
+
if target.resolve() == dispatcher.resolve():
|
|
120
|
+
return "unchanged"
|
|
121
|
+
if not dry_run:
|
|
122
|
+
target.unlink()
|
|
123
|
+
elif target.exists() and target.read_text(encoding="utf-8", errors="replace") == script:
|
|
656
124
|
return "unchanged"
|
|
657
|
-
if dry_run:
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
661
|
-
target.write_text(script, encoding="utf-8")
|
|
662
|
-
target.chmod(0o755)
|
|
125
|
+
if not dry_run:
|
|
126
|
+
atomic_write_text(target, script, mode=0o755)
|
|
663
127
|
return "installed"
|
|
664
128
|
|
|
665
129
|
|
|
666
|
-
def
|
|
667
|
-
|
|
668
|
-
return
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
# ---------------------------------------------------------------------------
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
def load_config() -> dict:
|
|
677
|
-
try:
|
|
678
|
-
return json.loads(config_file().read_text(encoding="utf-8"))
|
|
679
|
-
except (OSError, ValueError):
|
|
680
|
-
return {}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def save_config(config: dict, *, dry_run: bool = False) -> None:
|
|
684
|
-
if dry_run:
|
|
685
|
-
return
|
|
686
|
-
config_dir().mkdir(parents=True, exist_ok=True)
|
|
687
|
-
config_file().write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
# ---------------------------------------------------------------------------
|
|
691
|
-
# Dependencies
|
|
692
|
-
# ---------------------------------------------------------------------------
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def binary_status() -> dict[str, dict]:
|
|
696
|
-
"""Every external binary the commands need, and whether it is present."""
|
|
697
|
-
status = {}
|
|
698
|
-
for binary, users in required_binaries().items():
|
|
699
|
-
resolved = shutil.which(binary)
|
|
700
|
-
status[binary] = {
|
|
701
|
-
"path": resolved,
|
|
702
|
-
"present": bool(resolved),
|
|
703
|
-
"commands": list(users),
|
|
704
|
-
"hint": INSTALL_HINTS.get(binary, ""),
|
|
705
|
-
"system": binary in SYSTEM_BINARIES,
|
|
706
|
-
}
|
|
707
|
-
return status
|
|
130
|
+
def is_che_shim(path: Path) -> bool:
|
|
131
|
+
"""Whether ``path`` is a shim ``che install`` wrote, not a link or a stranger's file."""
|
|
132
|
+
return (
|
|
133
|
+
not path.is_symlink()
|
|
134
|
+
and path.is_file()
|
|
135
|
+
and SHIM_HEADER in path.read_text(encoding="utf-8", errors="replace")
|
|
136
|
+
)
|
|
708
137
|
|
|
709
138
|
|
|
710
139
|
def install_missing_binaries(names: list[str], *, dry_run: bool = False) -> bool:
|
|
711
|
-
"""Install missing tools with Homebrew. Returns whether
|
|
140
|
+
"""Install missing tools with Homebrew. Returns whether every one went in."""
|
|
712
141
|
formulae = sorted({BREW_FORMULA[name] for name in names if name in BREW_FORMULA})
|
|
713
142
|
if not formulae:
|
|
714
143
|
return True
|
|
@@ -735,225 +164,117 @@ class InstallPlan:
|
|
|
735
164
|
shells: list[str]
|
|
736
165
|
python: str
|
|
737
166
|
sounds: bool = True
|
|
738
|
-
completions: bool = True
|
|
739
167
|
path_shim: bool = True
|
|
740
168
|
replace_legacy: bool = False
|
|
741
169
|
dry_run: bool = False
|
|
742
170
|
|
|
171
|
+
def to_record(self, rc_files: list[Path]) -> InstallRecord:
|
|
172
|
+
"""What ``config.json`` keeps, so ``che update`` can repeat this install."""
|
|
173
|
+
return InstallRecord(
|
|
174
|
+
version=VERSION,
|
|
175
|
+
home=str(REPO),
|
|
176
|
+
python=self.python,
|
|
177
|
+
shells=self.shells,
|
|
178
|
+
rc_files=[str(path) for path in rc_files],
|
|
179
|
+
sounds=self.sounds,
|
|
180
|
+
path_shim=self.path_shim,
|
|
181
|
+
installed_at=datetime.now().isoformat(timespec="seconds"),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_record(cls, record: InstallRecord, *, python: str, dry_run: bool) -> InstallPlan:
|
|
186
|
+
"""The plan that repeats a recorded install under ``python``."""
|
|
187
|
+
return cls(
|
|
188
|
+
shells=list(record.shells),
|
|
189
|
+
python=python,
|
|
190
|
+
sounds=record.sounds,
|
|
191
|
+
path_shim=record.path_shim,
|
|
192
|
+
dry_run=dry_run,
|
|
193
|
+
)
|
|
194
|
+
|
|
743
195
|
|
|
744
196
|
@dataclass
|
|
745
197
|
class InstallResult:
|
|
746
198
|
rc_actions: list[tuple[Path, str]] = field(default_factory=list)
|
|
747
199
|
generated: list[Path] = field(default_factory=list)
|
|
748
|
-
legacy_removed: dict[
|
|
200
|
+
legacy_removed: dict[Path, list[str]] = field(default_factory=dict)
|
|
749
201
|
shim: str = "skipped"
|
|
750
202
|
|
|
751
203
|
|
|
204
|
+
def block_for(shell: str, plan: InstallPlan) -> str:
|
|
205
|
+
"""The rc block ``plan`` writes for ``shell``, and so what ``--print`` shows."""
|
|
206
|
+
return shellgen.rc_block(
|
|
207
|
+
SHELLS[shell].flavor,
|
|
208
|
+
home=REPO,
|
|
209
|
+
python=plan.python,
|
|
210
|
+
sounds=plan.sounds,
|
|
211
|
+
path_dir=shim_path().parent if plan.path_shim else None,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
752
215
|
def perform_install(plan: InstallPlan) -> InstallResult:
|
|
753
216
|
"""Do the install described by ``plan``. No prompting happens here."""
|
|
754
217
|
result = InstallResult()
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
218
|
+
try:
|
|
219
|
+
result.generated = shellgen.sync_generated(dry_run=plan.dry_run)
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
# The README lost a marker. The committed shell/ files still work, so
|
|
222
|
+
# the rc blocks that point at them can still be written.
|
|
223
|
+
warning(f"❌ {exc}")
|
|
758
224
|
|
|
759
225
|
for shell in plan.shells:
|
|
760
|
-
|
|
761
|
-
if
|
|
226
|
+
spec = SHELLS.get(shell)
|
|
227
|
+
if spec is None:
|
|
762
228
|
warning(f"❌ Unknown shell: {shell}")
|
|
763
229
|
continue
|
|
764
230
|
|
|
765
|
-
block =
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
python=plan.python,
|
|
769
|
-
sounds=plan.sounds,
|
|
770
|
-
)
|
|
771
|
-
if plan.path_shim and shell != "fish":
|
|
772
|
-
block = _with_path_line(block)
|
|
773
|
-
elif plan.path_shim and shell == "fish":
|
|
774
|
-
block = _with_fish_path_line(block)
|
|
775
|
-
|
|
776
|
-
for rc_file in target.rc_files:
|
|
231
|
+
block = block_for(shell, plan)
|
|
232
|
+
for rc_file in spec.rc_files:
|
|
233
|
+
original = text = read_rc(rc_file)
|
|
777
234
|
if plan.replace_legacy:
|
|
778
|
-
removed =
|
|
235
|
+
text, removed = without_legacy(text)
|
|
779
236
|
if removed:
|
|
780
|
-
result.legacy_removed[
|
|
781
|
-
action =
|
|
237
|
+
result.legacy_removed[rc_file] = removed
|
|
238
|
+
updated, action = with_block(text, block)
|
|
239
|
+
commit_rc(rc_file, original, updated, dry_run=plan.dry_run)
|
|
782
240
|
result.rc_actions.append((rc_file, action))
|
|
783
241
|
|
|
784
242
|
if plan.path_shim:
|
|
785
243
|
result.shim = install_shim(plan.python, dry_run=plan.dry_run)
|
|
786
244
|
|
|
787
|
-
|
|
788
|
-
{
|
|
789
|
-
"version": VERSION,
|
|
790
|
-
"home": str(REPO),
|
|
791
|
-
"python": plan.python,
|
|
792
|
-
"shells": plan.shells,
|
|
793
|
-
"rc_files": [str(path) for path, _ in result.rc_actions],
|
|
794
|
-
"sounds": plan.sounds,
|
|
795
|
-
"completions": plan.completions,
|
|
796
|
-
"path_shim": plan.path_shim,
|
|
797
|
-
"installed_at": datetime.now().isoformat(timespec="seconds"),
|
|
798
|
-
},
|
|
799
|
-
dry_run=plan.dry_run,
|
|
800
|
-
)
|
|
245
|
+
save_record(plan.to_record([path for path, _ in result.rc_actions]), dry_run=plan.dry_run)
|
|
801
246
|
return result
|
|
802
247
|
|
|
803
248
|
|
|
804
|
-
def
|
|
805
|
-
"""
|
|
806
|
-
directory = str(shim_path().parent)
|
|
807
|
-
line = f'case ":$PATH:" in *":{directory}:"*) ;; *) PATH="{directory}:$PATH" ;; esac'
|
|
808
|
-
return block.replace(END, f"{line}\n{END}")
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
def _with_fish_path_line(block: str) -> str:
|
|
812
|
-
directory = str(shim_path().parent)
|
|
813
|
-
line = f'if not contains "{directory}" $PATH\n set -gx PATH "{directory}" $PATH\nend'
|
|
814
|
-
return block.replace(shellgen.END_MARKER, f"{line}\n{shellgen.END_MARKER}")
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
# ---------------------------------------------------------------------------
|
|
818
|
-
# The first-run wizard
|
|
819
|
-
# ---------------------------------------------------------------------------
|
|
249
|
+
def run_install(plan: InstallPlan, *, install_deps: bool = False) -> int:
|
|
250
|
+
"""Install, say what happened, then fetch the missing tools when asked to.
|
|
820
251
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
╚═════╝╚═╝ ╚═╝╚══════╝
|
|
828
|
-
"""
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
def print_logo(subtitle: str = "") -> None:
|
|
832
|
-
colors = palette()
|
|
833
|
-
for line in LOGO.strip("\n").splitlines():
|
|
834
|
-
print(f"{colors.bright_magenta}{line}{colors.reset}")
|
|
835
|
-
if subtitle:
|
|
836
|
-
print(f" {colors.grey}{subtitle}{colors.reset}")
|
|
252
|
+
Returns the exit status: 1 when a tool the user asked for did not install.
|
|
253
|
+
"""
|
|
254
|
+
report(plan, perform_install(plan))
|
|
255
|
+
missing = missing_binaries() if install_deps else []
|
|
256
|
+
if not missing:
|
|
257
|
+
return 0
|
|
837
258
|
print()
|
|
259
|
+
return 0 if install_missing_binaries(missing, dry_run=plan.dry_run) else 1
|
|
838
260
|
|
|
839
261
|
|
|
840
|
-
def
|
|
841
|
-
"""
|
|
842
|
-
colors = palette()
|
|
843
|
-
print_logo(f"shell helpers, v{VERSION} - first-run setup")
|
|
262
|
+
def require_python() -> str | None:
|
|
263
|
+
"""The interpreter the wrappers will run, or None, said why, when none is new enough.
|
|
844
264
|
|
|
265
|
+
Writing a too-old one into every rc block would break every wrapper at once.
|
|
266
|
+
"""
|
|
845
267
|
python, version = find_python()
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
note(" Install one with `brew install python@3.13`, then run this again.")
|
|
852
|
-
return 1
|
|
853
|
-
|
|
854
|
-
print(
|
|
855
|
-
f" {colors.green}✓{colors.reset} python {version_text} {colors.grey}{python}{colors.reset}"
|
|
856
|
-
)
|
|
857
|
-
print(f" {colors.green}✓{colors.reset} repo {colors.grey}{REPO}{colors.reset}")
|
|
858
|
-
print(f" {colors.green}✓{colors.reset} {len(all_commands())} commands ready to wrap")
|
|
859
|
-
print()
|
|
860
|
-
|
|
861
|
-
targets = detect_shells()
|
|
862
|
-
selectable = [target for target in targets if target.installed or target.has_rc]
|
|
863
|
-
if not selectable:
|
|
864
|
-
selectable = [target for target in targets if target.name == "zsh"]
|
|
865
|
-
|
|
866
|
-
preselected = default_shell_selection(targets)
|
|
867
|
-
options = []
|
|
868
|
-
for target in selectable:
|
|
869
|
-
rc_names = ", ".join(str(path).replace(str(home()), "~") for path in target.rc_files)
|
|
870
|
-
label = target.name + (" (your login shell)" if target.is_login_shell else "")
|
|
871
|
-
options.append((label, rc_names))
|
|
872
|
-
|
|
873
|
-
picked = choose(
|
|
874
|
-
"Which shells should che install into?",
|
|
875
|
-
options,
|
|
876
|
-
selected=[index for index, target in enumerate(selectable) if target.name in preselected],
|
|
877
|
-
)
|
|
878
|
-
if not picked:
|
|
879
|
-
warning("Nothing selected - install cancelled.")
|
|
880
|
-
return 1
|
|
881
|
-
shells = [selectable[index].name for index in picked]
|
|
268
|
+
if python_ok(version):
|
|
269
|
+
return python
|
|
270
|
+
warning(f"❌ Python {MIN_PYTHON_TEXT}+ is required (found {version_text(version)}).")
|
|
271
|
+
note(f" Install one with `{PYTHON_HINT}`, then run this again.")
|
|
272
|
+
return None
|
|
882
273
|
|
|
883
|
-
print()
|
|
884
|
-
path_shim = confirm(
|
|
885
|
-
f"Add a `che` command to {str(shim_path()).replace(str(home()), '~')} "
|
|
886
|
-
"so scripts and cron can call it?",
|
|
887
|
-
default=True,
|
|
888
|
-
)
|
|
889
|
-
sounds = confirm("Play the playsound-N chime when a command finishes?", default=True)
|
|
890
|
-
|
|
891
|
-
legacy_found = {
|
|
892
|
-
rc_file: find_legacy(read_rc(rc_file))
|
|
893
|
-
for target in targets
|
|
894
|
-
if target.name in shells
|
|
895
|
-
for rc_file in target.rc_files
|
|
896
|
-
if find_legacy(read_rc(rc_file))
|
|
897
|
-
}
|
|
898
|
-
replace_legacy = False
|
|
899
|
-
if legacy_found:
|
|
900
|
-
print()
|
|
901
|
-
total = sum(len(names) for names in legacy_found.values())
|
|
902
|
-
note(f"Found {total} hand-written wrappers from before che managed them:")
|
|
903
|
-
for rc_file, names in legacy_found.items():
|
|
904
|
-
print(
|
|
905
|
-
f" {colors.grey}{rc_file}: {', '.join(names[:6])}"
|
|
906
|
-
f"{'…' if len(names) > 6 else ''}{colors.reset}"
|
|
907
|
-
)
|
|
908
|
-
replace_legacy = confirm(
|
|
909
|
-
"Replace them with the generated ones? (a backup is written first)", default=True
|
|
910
|
-
)
|
|
911
|
-
|
|
912
|
-
missing = [
|
|
913
|
-
name
|
|
914
|
-
for name, entry in binary_status().items()
|
|
915
|
-
if not entry["present"] and not entry["system"]
|
|
916
|
-
]
|
|
917
|
-
install_deps = False
|
|
918
|
-
if missing:
|
|
919
|
-
print()
|
|
920
|
-
note(f"Missing external tools: {', '.join(missing)}")
|
|
921
|
-
for name in missing:
|
|
922
|
-
hint = INSTALL_HINTS.get(name)
|
|
923
|
-
if hint:
|
|
924
|
-
print(f" {colors.grey}{name}: {hint}{colors.reset}")
|
|
925
|
-
if shutil.which("brew"):
|
|
926
|
-
install_deps = confirm("Install them now with Homebrew?", default=False)
|
|
927
|
-
|
|
928
|
-
print()
|
|
929
|
-
plan = InstallPlan(
|
|
930
|
-
shells=shells,
|
|
931
|
-
python=python,
|
|
932
|
-
sounds=sounds,
|
|
933
|
-
path_shim=path_shim,
|
|
934
|
-
replace_legacy=replace_legacy,
|
|
935
|
-
dry_run=dry_run,
|
|
936
|
-
)
|
|
937
|
-
result = perform_install(plan)
|
|
938
|
-
report(plan, result)
|
|
939
|
-
|
|
940
|
-
if install_deps:
|
|
941
|
-
print()
|
|
942
|
-
install_missing_binaries(missing, dry_run=dry_run)
|
|
943
|
-
|
|
944
|
-
print()
|
|
945
|
-
success("Done. Start using it with:")
|
|
946
|
-
print(
|
|
947
|
-
f" {colors.bold}exec {shells[0]}{colors.reset} {colors.grey}(reload your shell){colors.reset}"
|
|
948
|
-
)
|
|
949
|
-
print(
|
|
950
|
-
f" {colors.bold}che{colors.reset} {colors.grey}(browse every command){colors.reset}"
|
|
951
|
-
)
|
|
952
|
-
print(
|
|
953
|
-
f" {colors.bold}che doctor{colors.reset} {colors.grey}(check the install){colors.reset}"
|
|
954
|
-
)
|
|
955
|
-
return 0
|
|
956
274
|
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Reporting
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
957
278
|
|
|
958
279
|
# Past tense for what happened, present tense for what a dry run would do.
|
|
959
280
|
DRY_VERB = {
|
|
@@ -966,45 +287,47 @@ DRY_VERB = {
|
|
|
966
287
|
}
|
|
967
288
|
|
|
968
289
|
|
|
969
|
-
def
|
|
970
|
-
"""
|
|
290
|
+
def phrase(action: str, *, dry_run: bool) -> str:
|
|
291
|
+
"""``action`` as done, or as what a dry run would do."""
|
|
292
|
+
if not dry_run:
|
|
293
|
+
return action
|
|
971
294
|
colors = palette()
|
|
295
|
+
return f"{colors.yellow}would {DRY_VERB.get(action, action)}{colors.reset}"
|
|
972
296
|
|
|
973
|
-
def phrase(action: str) -> str:
|
|
974
|
-
if not plan.dry_run:
|
|
975
|
-
return action
|
|
976
|
-
return f"{colors.yellow}would {DRY_VERB.get(action, action)}{colors.reset}"
|
|
977
297
|
|
|
978
|
-
|
|
979
|
-
|
|
298
|
+
def mark(action: str = "") -> str:
|
|
299
|
+
"""What starts a report line: a grey ``=`` for something left alone, else a ``✓``."""
|
|
300
|
+
colors = palette()
|
|
301
|
+
if action == "unchanged":
|
|
302
|
+
return f"{colors.grey}={colors.reset}"
|
|
303
|
+
return f"{colors.green}✓{colors.reset}"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def report(plan: InstallPlan, result: InstallResult) -> None:
|
|
307
|
+
"""Print what an install did, in the same shape for wizard and --yes."""
|
|
308
|
+
colors = palette()
|
|
309
|
+
dry_run = plan.dry_run
|
|
980
310
|
|
|
981
311
|
for rc_file, action in result.rc_actions:
|
|
982
|
-
mark = (
|
|
983
|
-
f"{colors.grey}={colors.reset}"
|
|
984
|
-
if action == "unchanged"
|
|
985
|
-
else f"{colors.green}✓{colors.reset}"
|
|
986
|
-
)
|
|
987
|
-
print(f" {mark} {phrase(action)} block in {short(rc_file)}")
|
|
312
|
+
print(f" {mark(action)} {phrase(action, dry_run=dry_run)} block in {tildify(rc_file)}")
|
|
988
313
|
|
|
989
314
|
for rc_file, names in result.legacy_removed.items():
|
|
990
315
|
print(
|
|
991
|
-
f" {
|
|
992
|
-
f"{len(names)} legacy wrappers from {
|
|
316
|
+
f" {mark()} {phrase('removed', dry_run=dry_run)} "
|
|
317
|
+
f"{len(names)} legacy wrappers from {tildify(rc_file)}"
|
|
993
318
|
)
|
|
994
319
|
|
|
995
320
|
if result.generated:
|
|
996
321
|
print(
|
|
997
|
-
f" {
|
|
322
|
+
f" {mark()} {phrase('regenerated', dry_run=dry_run)} "
|
|
998
323
|
f"{len(result.generated)} files under shell/"
|
|
999
324
|
)
|
|
1000
325
|
|
|
1001
326
|
if plan.path_shim:
|
|
1002
|
-
|
|
1003
|
-
f"{
|
|
1004
|
-
|
|
1005
|
-
else f"{colors.green}✓{colors.reset}"
|
|
327
|
+
print(
|
|
328
|
+
f" {mark(result.shim)} che shim {phrase(result.shim, dry_run=dry_run)}: "
|
|
329
|
+
f"{tildify(shim_path())}"
|
|
1006
330
|
)
|
|
1007
|
-
print(f" {mark} che shim {phrase(result.shim)}: {short(shim_path())}")
|
|
1008
331
|
if not shim_on_path():
|
|
1009
332
|
print(
|
|
1010
333
|
f" {colors.grey} ({shim_path().parent} is added to PATH by the block){colors.reset}"
|
|
@@ -1012,182 +335,119 @@ def report(plan: InstallPlan, result: InstallResult) -> None:
|
|
|
1012
335
|
|
|
1013
336
|
|
|
1014
337
|
# ---------------------------------------------------------------------------
|
|
1015
|
-
#
|
|
338
|
+
# The first-run wizard
|
|
1016
339
|
# ---------------------------------------------------------------------------
|
|
1017
340
|
|
|
1018
|
-
OK, WARN, FAIL = "ok", "warn", "fail"
|
|
1019
341
|
|
|
342
|
+
def wizard(plan: InstallPlan, *, install_deps: bool = False) -> int:
|
|
343
|
+
"""Walk a first-time user through the whole setup.
|
|
1020
344
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
345
|
+
``plan`` is what is already decided. It preselects the shells, and a
|
|
346
|
+
question it already answers - ``--no-sounds``, ``--no-path-shim``,
|
|
347
|
+
``--replace-legacy`` or ``install_deps`` - is not asked again.
|
|
348
|
+
"""
|
|
349
|
+
colors = palette()
|
|
350
|
+
print_logo(f"shell helpers, v{VERSION} - first-run setup")
|
|
1025
351
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
"status": OK if version and version[:2] >= MIN_PYTHON else FAIL,
|
|
1032
|
-
"detail": f"{version_text} ({python})",
|
|
1033
|
-
"fix": "brew install python@3.13",
|
|
1034
|
-
}
|
|
1035
|
-
)
|
|
352
|
+
version = python_version(plan.python) or ()
|
|
353
|
+
print(f" {mark()} python {version_text(version)} {colors.grey}{plan.python}{colors.reset}")
|
|
354
|
+
print(f" {mark()} repo {colors.grey}{REPO}{colors.reset}")
|
|
355
|
+
print(f" {mark()} {len(all_commands())} commands ready to wrap")
|
|
356
|
+
print()
|
|
1036
357
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
"detail": str(REPO),
|
|
1042
|
-
"fix": "re-clone the repo",
|
|
1043
|
-
}
|
|
1044
|
-
)
|
|
358
|
+
shells = _ask_shells(plan.shells)
|
|
359
|
+
if not shells:
|
|
360
|
+
warning("Nothing selected - install cancelled.")
|
|
361
|
+
return 1
|
|
1045
362
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
{
|
|
1049
|
-
|
|
1050
|
-
"status": OK if not stale else WARN,
|
|
1051
|
-
"detail": "current"
|
|
1052
|
-
if not stale
|
|
1053
|
-
else f"{len(stale)} out of date: " + ", ".join(str(path) for path in stale),
|
|
1054
|
-
"fix": "che install",
|
|
1055
|
-
}
|
|
363
|
+
print()
|
|
364
|
+
path_shim = plan.path_shim and confirm(
|
|
365
|
+
f"Add a `che` command to {tildify(shim_path())} so scripts and cron can call it?",
|
|
366
|
+
default=True,
|
|
1056
367
|
)
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
checks.append(
|
|
1060
|
-
{
|
|
1061
|
-
"name": "install record",
|
|
1062
|
-
"status": OK if installed_version == VERSION else (WARN if config else FAIL),
|
|
1063
|
-
"detail": f"config {installed_version or 'missing'} vs che {VERSION}",
|
|
1064
|
-
"fix": "che install",
|
|
1065
|
-
}
|
|
368
|
+
sounds = plan.sounds and confirm(
|
|
369
|
+
"Play the playsound-N chime when a command finishes?", default=True
|
|
1066
370
|
)
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
watched = set(config.get("shells") or default_shell_selection(detect_shells()))
|
|
1072
|
-
watched.add(login_shell())
|
|
1073
|
-
|
|
1074
|
-
for target in detect_shells():
|
|
1075
|
-
if target.name not in watched:
|
|
1076
|
-
continue
|
|
1077
|
-
for rc_file in target.rc_files:
|
|
1078
|
-
text = read_rc(rc_file)
|
|
1079
|
-
has_block = find_block(text) is not None
|
|
1080
|
-
legacy = find_legacy(text)
|
|
1081
|
-
status = OK if has_block else (WARN if target.name == "sh" else FAIL)
|
|
1082
|
-
detail = "block installed" if has_block else "no che block"
|
|
1083
|
-
if legacy:
|
|
1084
|
-
status = WARN if has_block else status
|
|
1085
|
-
detail += f", {len(legacy)} legacy wrappers"
|
|
1086
|
-
|
|
1087
|
-
shadowing = find_shadowing(text)
|
|
1088
|
-
if shadowing:
|
|
1089
|
-
status = WARN if status == OK else status
|
|
1090
|
-
detail += ", shadowed: " + ", ".join(
|
|
1091
|
-
f"{name} ({how})" for name, how in shadowing[:3]
|
|
1092
|
-
)
|
|
1093
|
-
shadow_fix = (
|
|
1094
|
-
"the alias wins at the prompt; run `\\name` for the wrapper, "
|
|
1095
|
-
"or remove the alias"
|
|
1096
|
-
if any(how == "alias" for _, how in shadowing)
|
|
1097
|
-
else "move your definition above the che block"
|
|
1098
|
-
)
|
|
1099
|
-
checks.append(
|
|
1100
|
-
{
|
|
1101
|
-
"name": f"{target.name}: {str(rc_file).replace(str(home()), '~')}",
|
|
1102
|
-
"status": status,
|
|
1103
|
-
"detail": detail,
|
|
1104
|
-
"fix": (
|
|
1105
|
-
"che install --replace-legacy"
|
|
1106
|
-
if legacy
|
|
1107
|
-
else (shadow_fix if shadowing else "che install")
|
|
1108
|
-
),
|
|
1109
|
-
}
|
|
1110
|
-
)
|
|
1111
|
-
|
|
1112
|
-
shim = shim_path()
|
|
1113
|
-
if shim.exists() and shim_on_path():
|
|
1114
|
-
shim_status, shim_detail, shim_fix = OK, str(shim), ""
|
|
1115
|
-
elif shim.exists():
|
|
1116
|
-
shim_status = WARN
|
|
1117
|
-
shim_detail = f"{shim} (PATH picks it up on your next shell)"
|
|
1118
|
-
shim_fix = f"exec {login_shell() or 'zsh'}"
|
|
1119
|
-
else:
|
|
1120
|
-
shim_status, shim_detail, shim_fix = WARN, "not installed", "che install"
|
|
1121
|
-
checks.append(
|
|
1122
|
-
{"name": "che on PATH", "status": shim_status, "detail": shim_detail, "fix": shim_fix}
|
|
371
|
+
replace_legacy = plan.replace_legacy or _ask_legacy(shells)
|
|
372
|
+
install_deps = install_deps or _ask_tools()
|
|
373
|
+
plan = replace(
|
|
374
|
+
plan, shells=shells, path_shim=path_shim, sounds=sounds, replace_legacy=replace_legacy
|
|
1123
375
|
)
|
|
1124
376
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
if len(entry["commands"]) > 3:
|
|
1128
|
-
used_by += f" +{len(entry['commands']) - 3}"
|
|
1129
|
-
checks.append(
|
|
1130
|
-
{
|
|
1131
|
-
"name": f"tool: {binary}",
|
|
1132
|
-
"status": OK if entry["present"] else WARN,
|
|
1133
|
-
"detail": entry["path"] or f"missing - needed by {used_by}",
|
|
1134
|
-
"fix": entry["hint"],
|
|
1135
|
-
}
|
|
1136
|
-
)
|
|
377
|
+
print()
|
|
378
|
+
status = run_install(plan, install_deps=install_deps)
|
|
1137
379
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
{
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
+ ("" if not missing_scripts else f", missing: {', '.join(missing_scripts)}"),
|
|
1149
|
-
"fix": update_command(),
|
|
1150
|
-
}
|
|
380
|
+
print()
|
|
381
|
+
success("Done. Start using it with:")
|
|
382
|
+
print(
|
|
383
|
+
f" {colors.bold}exec {shells[0]}{colors.reset} {colors.grey}(reload your shell){colors.reset}"
|
|
384
|
+
)
|
|
385
|
+
print(
|
|
386
|
+
f" {colors.bold}che{colors.reset} {colors.grey}(browse every command){colors.reset}"
|
|
387
|
+
)
|
|
388
|
+
print(
|
|
389
|
+
f" {colors.bold}che doctor{colors.reset} {colors.grey}(check the install){colors.reset}"
|
|
1151
390
|
)
|
|
391
|
+
return status
|
|
1152
392
|
|
|
1153
|
-
return checks
|
|
1154
393
|
|
|
394
|
+
def _ask_shells(preselected: list[str]) -> list[str]:
|
|
395
|
+
"""The shells to install into: those present on this machine, ``preselected`` ticked."""
|
|
396
|
+
specs = list(SHELLS.values())
|
|
397
|
+
selectable = [spec for spec in specs if spec.installed or spec.has_rc] or [SHELLS["zsh"]]
|
|
398
|
+
picked = choose(
|
|
399
|
+
"Which shells should che install into?",
|
|
400
|
+
[
|
|
401
|
+
(
|
|
402
|
+
spec.name + (" (your login shell)" if spec.is_login_shell else ""),
|
|
403
|
+
", ".join(tildify(path) for path in spec.rc_files),
|
|
404
|
+
)
|
|
405
|
+
for spec in selectable
|
|
406
|
+
],
|
|
407
|
+
selected=[index for index, spec in enumerate(selectable) if spec.name in preselected],
|
|
408
|
+
)
|
|
409
|
+
return [selectable[index].name for index in picked]
|
|
1155
410
|
|
|
1156
|
-
def doctor(*, as_json: bool = False, verbose: bool = False) -> int:
|
|
1157
|
-
checks = diagnose()
|
|
1158
411
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
412
|
+
def _ask_legacy(shells: list[str]) -> bool:
|
|
413
|
+
"""Whether to replace the hand-written wrappers in ``shells``' rc files, if any."""
|
|
414
|
+
found = {
|
|
415
|
+
rc_file: names
|
|
416
|
+
for shell in shells
|
|
417
|
+
for rc_file in SHELLS[shell].rc_files
|
|
418
|
+
if (names := find_legacy(read_rc(rc_file)))
|
|
419
|
+
}
|
|
420
|
+
if not found:
|
|
421
|
+
return False
|
|
1162
422
|
|
|
1163
423
|
colors = palette()
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
424
|
+
print()
|
|
425
|
+
total = sum(len(names) for names in found.values())
|
|
426
|
+
note(f"Found {total} hand-written wrappers from before che managed them:")
|
|
427
|
+
for rc_file, names in found.items():
|
|
428
|
+
print(
|
|
429
|
+
f" {colors.grey}{rc_file}: {', '.join(names[:6])}"
|
|
430
|
+
f"{'…' if len(names) > 6 else ''}{colors.reset}"
|
|
431
|
+
)
|
|
432
|
+
return confirm(
|
|
433
|
+
"Replace them with the generated ones? (a backup is written first)", default=True
|
|
434
|
+
)
|
|
1169
435
|
|
|
1170
|
-
print_logo(f"doctor - che {VERSION}")
|
|
1171
|
-
width = max(len(check["name"]) for check in checks) + 2
|
|
1172
436
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
if check["status"] != OK and check["fix"]:
|
|
1179
|
-
print(f" {colors.grey}→ {check['fix']}{colors.reset}")
|
|
437
|
+
def _ask_tools() -> bool:
|
|
438
|
+
"""Whether to install the missing external tools, after listing them."""
|
|
439
|
+
missing = missing_binaries()
|
|
440
|
+
if not missing:
|
|
441
|
+
return False
|
|
1180
442
|
|
|
1181
|
-
|
|
1182
|
-
warnings = sum(1 for check in checks if check["status"] == WARN)
|
|
443
|
+
colors = palette()
|
|
1183
444
|
print()
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
return 1 if failures else 0
|
|
445
|
+
note(f"Missing external tools: {', '.join(missing)}")
|
|
446
|
+
for name in missing:
|
|
447
|
+
hint = INSTALL_HINTS.get(name)
|
|
448
|
+
if hint:
|
|
449
|
+
print(f" {colors.grey}{name}: {hint}{colors.reset}")
|
|
450
|
+
return bool(shutil.which("brew")) and confirm("Install them now with Homebrew?", default=False)
|
|
1191
451
|
|
|
1192
452
|
|
|
1193
453
|
# ---------------------------------------------------------------------------
|
|
@@ -1195,26 +455,6 @@ def doctor(*, as_json: bool = False, verbose: bool = False) -> int:
|
|
|
1195
455
|
# ---------------------------------------------------------------------------
|
|
1196
456
|
|
|
1197
457
|
|
|
1198
|
-
def install_source() -> str:
|
|
1199
|
-
"""How this copy of che got here: ``git`` for a clone, ``npm`` for a package.
|
|
1200
|
-
|
|
1201
|
-
The npm package is the same tree without ``.git``, unpacked under a
|
|
1202
|
-
``node_modules`` directory, so that is the whole test.
|
|
1203
|
-
"""
|
|
1204
|
-
if (REPO / ".git").exists():
|
|
1205
|
-
return "git"
|
|
1206
|
-
if "node_modules" in REPO.parts:
|
|
1207
|
-
return "npm"
|
|
1208
|
-
return "unknown"
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
def update_command() -> str:
|
|
1212
|
-
"""The command that fetches a newer che for this kind of install."""
|
|
1213
|
-
if install_source() == "git":
|
|
1214
|
-
return "git pull"
|
|
1215
|
-
return f"npm install -g {PACKAGE_NAME}@latest"
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
458
|
def update(*, check_only: bool = False, dry_run: bool = False) -> int:
|
|
1219
459
|
"""Pull the latest scripts, then refresh whatever is installed.
|
|
1220
460
|
|
|
@@ -1227,7 +467,7 @@ def update(*, check_only: bool = False, dry_run: bool = False) -> int:
|
|
|
1227
467
|
``git fetch failed``.
|
|
1228
468
|
"""
|
|
1229
469
|
colors = palette()
|
|
1230
|
-
if
|
|
470
|
+
if not is_git_checkout():
|
|
1231
471
|
warning("❌ This copy of che is not a git checkout, so `che update` cannot pull it.")
|
|
1232
472
|
note(f" Update it with `{update_command()}`, then `che install` to refresh the wrappers.")
|
|
1233
473
|
return 1
|
|
@@ -1235,110 +475,109 @@ def update(*, check_only: bool = False, dry_run: bool = False) -> int:
|
|
|
1235
475
|
warning("❌ git is not installed, so che cannot update itself.")
|
|
1236
476
|
return 1
|
|
1237
477
|
|
|
1238
|
-
before = run(["git", "rev-parse", "HEAD"], cwd=REPO, capture=True, check=False).stdout.strip()
|
|
1239
|
-
|
|
1240
478
|
info("Fetching…")
|
|
1241
|
-
|
|
1242
|
-
if fetch.returncode != 0:
|
|
479
|
+
if run(["git", "fetch", "--prune"], cwd=REPO, check=False).returncode != 0:
|
|
1243
480
|
warning("❌ git fetch failed.")
|
|
1244
481
|
return 1
|
|
1245
482
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
)
|
|
1249
|
-
count = behind.stdout.strip() if behind.returncode == 0 else "?"
|
|
1250
|
-
|
|
1251
|
-
if count == "0":
|
|
1252
|
-
success("Already up to date.")
|
|
1253
|
-
elif check_only:
|
|
1254
|
-
note(f"{count} commit(s) available. Run `che update` to apply.")
|
|
1255
|
-
return 0
|
|
1256
|
-
else:
|
|
1257
|
-
dirty = run(
|
|
1258
|
-
["git", "status", "--porcelain", "--untracked-files=no"],
|
|
1259
|
-
cwd=REPO,
|
|
1260
|
-
capture=True,
|
|
1261
|
-
check=False,
|
|
1262
|
-
).stdout.strip()
|
|
1263
|
-
if dirty:
|
|
1264
|
-
warning("❌ The repo has uncommitted changes; not pulling.")
|
|
1265
|
-
note(" Commit or stash them, then run `che update` again.")
|
|
1266
|
-
return 1
|
|
1267
|
-
|
|
1268
|
-
if dry_run:
|
|
1269
|
-
note(f"would pull {count} commit(s)")
|
|
1270
|
-
else:
|
|
1271
|
-
pull = run(["git", "pull", "--ff-only"], cwd=REPO, check=False)
|
|
1272
|
-
if pull.returncode != 0:
|
|
1273
|
-
warning("❌ git pull --ff-only failed - the branch has diverged.")
|
|
1274
|
-
return 1
|
|
1275
|
-
|
|
1276
|
-
after = run(["git", "rev-parse", "HEAD"], cwd=REPO, capture=True, check=False).stdout.strip()
|
|
1277
|
-
if before and after and before != after:
|
|
1278
|
-
print()
|
|
1279
|
-
note("What changed:")
|
|
1280
|
-
log = run(
|
|
1281
|
-
["git", "log", "--oneline", "--no-decorate", f"{before}..{after}"],
|
|
1282
|
-
cwd=REPO,
|
|
1283
|
-
capture=True,
|
|
1284
|
-
check=False,
|
|
1285
|
-
).stdout
|
|
1286
|
-
for line in log.splitlines()[:20]:
|
|
1287
|
-
print(f" {colors.grey}{line}{colors.reset}")
|
|
483
|
+
# Empty when the branch has no upstream or HEAD is detached: git explains
|
|
484
|
+
# on stderr and prints nothing.
|
|
485
|
+
count = run_output(["git", "rev-list", "--count", "HEAD..@{upstream}"], check=False, cwd=REPO)
|
|
1288
486
|
|
|
487
|
+
if not count:
|
|
488
|
+
warning("❌ The checked-out branch has no upstream to pull from.")
|
|
489
|
+
note(" Check out a branch that tracks origin, usually main, then run `che update` again.")
|
|
490
|
+
return 1
|
|
1289
491
|
if check_only:
|
|
492
|
+
if count == "0":
|
|
493
|
+
success("Already up to date.")
|
|
494
|
+
else:
|
|
495
|
+
note(f"{count} commit(s) available. Run `che update` to apply.")
|
|
1290
496
|
return 0
|
|
1291
497
|
|
|
1292
|
-
|
|
1293
|
-
if
|
|
498
|
+
python = require_python()
|
|
499
|
+
if python is None:
|
|
500
|
+
return 1
|
|
501
|
+
if count == "0":
|
|
502
|
+
success("Already up to date.")
|
|
503
|
+
elif not _pull(count, dry_run=dry_run):
|
|
504
|
+
return 1
|
|
505
|
+
|
|
506
|
+
record = load_record()
|
|
507
|
+
if record is None:
|
|
1294
508
|
note("che is not installed yet; running the installer.")
|
|
1295
|
-
return wizard(dry_run=dry_run)
|
|
509
|
+
return wizard(InstallPlan(shells=default_shell_selection(), python=python, dry_run=dry_run))
|
|
1296
510
|
|
|
1297
511
|
print()
|
|
1298
|
-
|
|
1299
|
-
shells=config.get("shells", default_shell_selection(detect_shells())),
|
|
1300
|
-
python=find_python()[0],
|
|
1301
|
-
sounds=config.get("sounds", True),
|
|
1302
|
-
path_shim=config.get("path_shim", True),
|
|
1303
|
-
dry_run=dry_run,
|
|
1304
|
-
)
|
|
1305
|
-
result = perform_install(plan)
|
|
1306
|
-
report(plan, result)
|
|
512
|
+
status = run_install(InstallPlan.from_record(record, python=python, dry_run=dry_run))
|
|
1307
513
|
|
|
1308
514
|
print()
|
|
1309
515
|
success("Updated. Reload your shell to pick up new commands:")
|
|
1310
516
|
print(f" {colors.bold}exec {login_shell() or 'zsh'}{colors.reset}")
|
|
1311
|
-
return
|
|
517
|
+
return status
|
|
1312
518
|
|
|
1313
519
|
|
|
1314
|
-
def
|
|
520
|
+
def _pull(count: str, *, dry_run: bool) -> bool:
|
|
521
|
+
"""Fast-forward the clone by its ``count`` new commits, then list them."""
|
|
522
|
+
if has_tracked_changes(REPO):
|
|
523
|
+
warning("❌ The repo has uncommitted changes; not pulling.")
|
|
524
|
+
note(" Commit or stash them, then run `che update` again.")
|
|
525
|
+
return False
|
|
526
|
+
if dry_run:
|
|
527
|
+
note(f"would pull {count} commit(s)")
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
before = run_output(["git", "rev-parse", "HEAD"], check=False, cwd=REPO)
|
|
531
|
+
if run(["git", "pull", "--ff-only"], cwd=REPO, check=False).returncode != 0:
|
|
532
|
+
warning("❌ git pull --ff-only failed - the branch has diverged.")
|
|
533
|
+
return False
|
|
534
|
+
|
|
1315
535
|
colors = palette()
|
|
536
|
+
print()
|
|
537
|
+
note("What changed:")
|
|
538
|
+
log = run_output(
|
|
539
|
+
["git", "log", "--oneline", "--no-decorate", f"{before}..HEAD"], check=False, cwd=REPO
|
|
540
|
+
)
|
|
541
|
+
for line in log.splitlines()[:20]:
|
|
542
|
+
print(f" {colors.grey}{line}{colors.reset}")
|
|
543
|
+
return True
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def uninstall(*, dry_run: bool = False, purge: bool = False) -> int:
|
|
547
|
+
"""Take out what ``che install`` put in: the rc blocks, the shim and the record.
|
|
548
|
+
|
|
549
|
+
``purge`` also deletes the whole config directory, backups included, and
|
|
550
|
+
the state directory, which holds the menu's per-prompt input history.
|
|
551
|
+
"""
|
|
552
|
+
removed = phrase("removed", dry_run=dry_run)
|
|
1316
553
|
touched = False
|
|
1317
554
|
|
|
1318
|
-
for
|
|
1319
|
-
for rc_file in
|
|
1320
|
-
|
|
555
|
+
for spec in SHELLS.values():
|
|
556
|
+
for rc_file in spec.rc_files:
|
|
557
|
+
original = read_rc(rc_file)
|
|
558
|
+
if commit_rc(rc_file, original, without_block(original), dry_run=dry_run):
|
|
1321
559
|
touched = True
|
|
1322
|
-
|
|
1323
|
-
print(f" {colors.green}✓{colors.reset} removed block from {display}")
|
|
1324
|
-
|
|
1325
|
-
shim = shim_path()
|
|
1326
|
-
if shim.exists():
|
|
1327
|
-
touched = True
|
|
1328
|
-
if not dry_run:
|
|
1329
|
-
shim.unlink()
|
|
1330
|
-
print(f" {colors.green}✓{colors.reset} removed {shim}")
|
|
560
|
+
print(f" {mark()} {removed} block from {tildify(rc_file)}")
|
|
1331
561
|
|
|
1332
|
-
if purge
|
|
562
|
+
leftovers = [config_dir(), state_dir()] if purge else [config_file()]
|
|
563
|
+
if is_che_shim(shim_path()):
|
|
564
|
+
leftovers.insert(0, shim_path())
|
|
565
|
+
for path in leftovers:
|
|
566
|
+
if not path.exists():
|
|
567
|
+
continue
|
|
1333
568
|
touched = True
|
|
1334
569
|
if not dry_run:
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
570
|
+
if path.is_dir():
|
|
571
|
+
shutil.rmtree(path)
|
|
572
|
+
else:
|
|
573
|
+
path.unlink()
|
|
574
|
+
print(f" {mark()} {removed} {tildify(path)}")
|
|
1339
575
|
|
|
1340
576
|
if not touched:
|
|
1341
577
|
note("Nothing to remove - che was not installed here.")
|
|
578
|
+
elif dry_run:
|
|
579
|
+
print()
|
|
580
|
+
note("Dry run: nothing was removed.")
|
|
1342
581
|
else:
|
|
1343
582
|
print()
|
|
1344
583
|
success("Uninstalled. The repo itself is untouched; delete it to finish.")
|
|
@@ -1350,9 +589,9 @@ def uninstall(*, dry_run: bool = False, purge: bool = False) -> int:
|
|
|
1350
589
|
# ---------------------------------------------------------------------------
|
|
1351
590
|
|
|
1352
591
|
|
|
1353
|
-
def build_install_parser(
|
|
592
|
+
def build_install_parser() -> BoolFlagParser:
|
|
1354
593
|
parser = build_parser(
|
|
1355
|
-
prog=
|
|
594
|
+
prog="che install",
|
|
1356
595
|
description="📥 Install the che shell wrappers into your shell startup files.",
|
|
1357
596
|
epilog=(
|
|
1358
597
|
"Examples:\n"
|
|
@@ -1373,143 +612,109 @@ def build_install_parser(prog: str = "che install"):
|
|
|
1373
612
|
add_bool_flag(parser, "--all-shells", help="Install into every shell present on this machine")
|
|
1374
613
|
add_bool_flag(parser, "--yes", help="Skip the wizard and use the defaults")
|
|
1375
614
|
add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
|
|
615
|
+
# Accepted and ignored: the completions load with the wrappers and cannot
|
|
616
|
+
# be left out. Hidden, but kept so an existing `--no-completions` still runs.
|
|
1376
617
|
add_bool_flag(
|
|
1377
618
|
parser,
|
|
1378
619
|
"--no-completions",
|
|
1379
|
-
dest="no_completions",
|
|
1380
620
|
allow_value=False,
|
|
1381
|
-
help=
|
|
621
|
+
help=argparse.SUPPRESS,
|
|
1382
622
|
)
|
|
1383
623
|
add_bool_flag(
|
|
1384
624
|
parser,
|
|
1385
625
|
"--no-path-shim",
|
|
1386
|
-
dest="no_path_shim",
|
|
1387
626
|
allow_value=False,
|
|
1388
627
|
help="Do not put a `che` executable in ~/.local/bin",
|
|
1389
628
|
)
|
|
1390
629
|
add_bool_flag(
|
|
1391
630
|
parser,
|
|
1392
631
|
"--no-sounds",
|
|
1393
|
-
dest="no_sounds",
|
|
1394
632
|
allow_value=False,
|
|
1395
633
|
help="Do not call playsound-N when a command finishes",
|
|
1396
634
|
)
|
|
1397
635
|
add_bool_flag(
|
|
1398
636
|
parser,
|
|
1399
637
|
"--replace-legacy",
|
|
1400
|
-
dest="replace_legacy",
|
|
1401
638
|
help="Remove hand-written wrappers this repo now generates",
|
|
1402
639
|
)
|
|
1403
640
|
add_bool_flag(
|
|
1404
641
|
parser,
|
|
1405
642
|
"--install-deps",
|
|
1406
|
-
dest="install_deps",
|
|
1407
643
|
help="Install missing external tools with Homebrew",
|
|
1408
644
|
)
|
|
1409
645
|
add_bool_flag(parser, "--print", dest="print_block", help="Print the rc block and exit")
|
|
1410
|
-
add_bool_flag(parser, "--check", help="Verify the generated files are current (for CI)")
|
|
1411
|
-
add_bool_flag(parser, "--generate", help="Rewrite the files under shell/ and exit")
|
|
1412
646
|
return parser
|
|
1413
647
|
|
|
1414
648
|
|
|
1415
649
|
def install_main(argv: list[str] | None = None) -> int:
|
|
1416
|
-
|
|
1417
|
-
args = parser.parse_args(argv)
|
|
1418
|
-
|
|
1419
|
-
if args.generate or args.check:
|
|
1420
|
-
try:
|
|
1421
|
-
_expected_files()
|
|
1422
|
-
except ValueError as exc:
|
|
1423
|
-
warning(f"❌ {exc}")
|
|
1424
|
-
return 1
|
|
1425
|
-
|
|
1426
|
-
if args.generate:
|
|
1427
|
-
changed = write_generated(dry_run=args.dry_run)
|
|
1428
|
-
for relative in changed:
|
|
1429
|
-
note(f"wrote {relative}")
|
|
1430
|
-
success(f"✓ {len(changed) or 'no'} generated file(s) updated")
|
|
1431
|
-
return 0
|
|
1432
|
-
|
|
1433
|
-
if args.check:
|
|
1434
|
-
stale = check_generated()
|
|
1435
|
-
if stale:
|
|
1436
|
-
warning("❌ Generated files are out of date:")
|
|
1437
|
-
for path in stale:
|
|
1438
|
-
note(f" {path}")
|
|
1439
|
-
note(" Run `che install` (or `bun run generate`) and commit the result.")
|
|
1440
|
-
return 1
|
|
1441
|
-
success("✓ Generated shell files match bin/commands.py")
|
|
1442
|
-
return 0
|
|
1443
|
-
|
|
1444
|
-
python, version = find_python()
|
|
650
|
+
args = build_install_parser().parse_args(argv)
|
|
1445
651
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
WRAPPER_FOR_SHELL[shell], home=REPO, python=python, sounds=not args.no_sounds
|
|
1452
|
-
),
|
|
1453
|
-
end="",
|
|
1454
|
-
)
|
|
1455
|
-
return 0
|
|
1456
|
-
|
|
1457
|
-
if not args.yes and not args.shells and not args.all_shells and is_interactive():
|
|
1458
|
-
return wizard(dry_run=args.dry_run)
|
|
1459
|
-
|
|
1460
|
-
if not version or version[:2] < MIN_PYTHON:
|
|
1461
|
-
warning(f"❌ Python {'.'.join(str(p) for p in MIN_PYTHON)}+ is required.")
|
|
652
|
+
shells = _requested_shells(args)
|
|
653
|
+
if shells is None:
|
|
654
|
+
return 1
|
|
655
|
+
python = require_python()
|
|
656
|
+
if python is None:
|
|
1462
657
|
return 1
|
|
1463
658
|
|
|
1464
659
|
plan = InstallPlan(
|
|
1465
|
-
shells=
|
|
660
|
+
shells=shells,
|
|
1466
661
|
python=python,
|
|
1467
662
|
sounds=not args.no_sounds,
|
|
1468
|
-
completions=not args.no_completions,
|
|
1469
663
|
path_shim=not args.no_path_shim,
|
|
1470
664
|
replace_legacy=args.replace_legacy,
|
|
1471
665
|
dry_run=args.dry_run,
|
|
1472
666
|
)
|
|
1473
667
|
|
|
668
|
+
if args.print_block:
|
|
669
|
+
for shell in plan.shells:
|
|
670
|
+
print(block_for(shell, plan), end="")
|
|
671
|
+
return 0
|
|
672
|
+
|
|
673
|
+
if not args.yes and not args.shells and not args.all_shells and is_interactive():
|
|
674
|
+
return wizard(plan, install_deps=args.install_deps)
|
|
675
|
+
|
|
1474
676
|
print_logo(f"installing che {VERSION}")
|
|
1475
|
-
|
|
1476
|
-
report(plan, result)
|
|
1477
|
-
|
|
1478
|
-
if args.install_deps:
|
|
1479
|
-
missing = [
|
|
1480
|
-
name
|
|
1481
|
-
for name, entry in binary_status().items()
|
|
1482
|
-
if not entry["present"] and not entry["system"]
|
|
1483
|
-
]
|
|
1484
|
-
if missing:
|
|
1485
|
-
print()
|
|
1486
|
-
install_missing_binaries(missing, dry_run=args.dry_run)
|
|
677
|
+
status = run_install(plan, install_deps=args.install_deps)
|
|
1487
678
|
|
|
1488
679
|
print()
|
|
1489
680
|
success("Reload your shell to pick up the wrappers: exec " + (login_shell() or "zsh"))
|
|
1490
|
-
return
|
|
681
|
+
return status
|
|
1491
682
|
|
|
1492
683
|
|
|
1493
|
-
def _requested_shells(args) -> list[str]:
|
|
1494
|
-
|
|
684
|
+
def _requested_shells(args: argparse.Namespace) -> list[str] | None:
|
|
685
|
+
"""The shells the flags ask for, or None, said why, when one is unknown."""
|
|
1495
686
|
if args.all_shells:
|
|
1496
|
-
return [
|
|
687
|
+
return [spec.name for spec in SHELLS.values() if spec.installed and not spec.opt_in]
|
|
1497
688
|
if args.shells:
|
|
1498
689
|
requested = [part.strip() for part in args.shells.split(",") if part.strip()]
|
|
1499
|
-
unknown = [name for name in requested if name not in
|
|
690
|
+
unknown = [name for name in requested if name not in SHELLS]
|
|
1500
691
|
if unknown:
|
|
1501
692
|
warning(f"❌ Unknown shell(s): {', '.join(unknown)}")
|
|
1502
|
-
note(f" Known: {', '.join(
|
|
1503
|
-
|
|
693
|
+
note(f" Known: {', '.join(SHELLS)}")
|
|
694
|
+
return None
|
|
1504
695
|
return requested
|
|
1505
|
-
return default_shell_selection(
|
|
696
|
+
return default_shell_selection()
|
|
1506
697
|
|
|
1507
698
|
|
|
1508
|
-
def
|
|
1509
|
-
|
|
699
|
+
def update_main(argv: list[str] | None = None) -> int:
|
|
700
|
+
parser = build_parser(
|
|
701
|
+
prog="che update", description="⬆️ Update the scripts, then refresh the wrappers."
|
|
702
|
+
)
|
|
703
|
+
add_bool_flag(parser, "--check", help="Report whether an update is available, apply nothing")
|
|
704
|
+
add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
|
|
705
|
+
args = parser.parse_args(argv)
|
|
706
|
+
return update(check_only=args.check, dry_run=args.dry_run)
|
|
1510
707
|
|
|
1511
708
|
|
|
1512
|
-
|
|
1513
|
-
|
|
709
|
+
def uninstall_main(argv: list[str] | None = None) -> int:
|
|
710
|
+
parser = build_parser(
|
|
711
|
+
prog="che uninstall", description="🧽 Remove the wrappers, shim and completions."
|
|
712
|
+
)
|
|
713
|
+
add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
|
|
714
|
+
add_bool_flag(parser, "--purge", help="Also delete ~/.config/che and the menu's history")
|
|
715
|
+
args = parser.parse_args(argv)
|
|
716
|
+
return uninstall(dry_run=args.dry_run, purge=args.purge)
|
|
717
|
+
|
|
1514
718
|
|
|
1515
|
-
|
|
719
|
+
if __name__ == "__main__":
|
|
720
|
+
run_cli(install_main)
|