dev-setup 1.0.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.
dev_setup/generic.py ADDED
@@ -0,0 +1,302 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from dev_setup.base import Tool
10
+
11
+ CUSTOM_DIR = Path.home() / ".config" / "dev-setup" / "packages"
12
+ _CUSTOM_DIR = CUSTOM_DIR
13
+
14
+
15
+ class GenericTool(Tool):
16
+ category = "custom"
17
+ builtin = False
18
+
19
+ def __init__(
20
+ self,
21
+ key: str,
22
+ name: str,
23
+ description: str = "",
24
+ category: str = "custom",
25
+ install_type: str = "unknown",
26
+ check_cmd: str = "",
27
+ npm_name: str = "",
28
+ pip_name: str = "",
29
+ git_url: str = "",
30
+ git_install_cmd: str = "",
31
+ git_remove_cmd: str = "",
32
+ apt_packages: str = "",
33
+ script_url: str = "",
34
+ install_script: str = "",
35
+ remove_script: str = "",
36
+ help_cmd: str = "",
37
+ ) -> None:
38
+ self.key = key
39
+ self.name = name
40
+ self.description = description
41
+ self.category = category
42
+ self.install_type = install_type
43
+ self.check_cmd = check_cmd
44
+ self.npm_name = npm_name
45
+ self.pip_name = pip_name
46
+ self.git_url = git_url
47
+ self.git_install_cmd = git_install_cmd
48
+ self.git_remove_cmd = git_remove_cmd
49
+ self.apt_packages = apt_packages
50
+ self.script_url = script_url
51
+ self.install_script = install_script
52
+ self.remove_script = remove_script
53
+ self.help_cmd = help_cmd
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict, key: str) -> "GenericTool":
57
+ return cls(
58
+ key=key,
59
+ name=data.get("name", key),
60
+ description=data.get("description", ""),
61
+ category=data.get("category", "custom"),
62
+ install_type=data.get("type", "unknown"),
63
+ check_cmd=data.get("check_cmd", ""),
64
+ npm_name=data.get("npm_name", ""),
65
+ pip_name=data.get("pip_name", ""),
66
+ git_url=data.get("git_url", ""),
67
+ git_install_cmd=data.get("git_install_cmd", ""),
68
+ git_remove_cmd=data.get("git_remove_cmd", ""),
69
+ apt_packages=data.get("apt_packages", ""),
70
+ script_url=data.get("script_url", ""),
71
+ install_script=data.get("install_script", ""),
72
+ remove_script=data.get("remove_script", ""),
73
+ help_cmd=data.get("help_cmd", ""),
74
+ )
75
+
76
+ def to_dict(self) -> dict:
77
+ d: dict = {
78
+ "name": self.name,
79
+ "description": self.description,
80
+ "category": self.category,
81
+ "type": self.install_type,
82
+ }
83
+ for field, val in [
84
+ ("check_cmd", self.check_cmd),
85
+ ("npm_name", self.npm_name),
86
+ ("pip_name", self.pip_name),
87
+ ("git_url", self.git_url),
88
+ ("git_install_cmd", self.git_install_cmd),
89
+ ("git_remove_cmd", self.git_remove_cmd),
90
+ ("apt_packages", self.apt_packages),
91
+ ("script_url", self.script_url),
92
+ ("install_script", self.install_script),
93
+ ("remove_script", self.remove_script),
94
+ ("help_cmd", self.help_cmd),
95
+ ]:
96
+ if val:
97
+ d[field] = val
98
+ return d
99
+
100
+ def save(self) -> None:
101
+ CUSTOM_DIR.mkdir(parents=True, exist_ok=True)
102
+ path = CUSTOM_DIR / f"{self.key}.json"
103
+ path.write_text(json.dumps(self.to_dict(), indent=2))
104
+
105
+ def is_installed(self) -> bool:
106
+ if self.check_cmd:
107
+ return shutil.which(self.check_cmd) is not None
108
+ t = self.install_type
109
+ if t == "npm":
110
+ return self.npm_name and _npm_global_installed(self.npm_name)
111
+ if t == "pip":
112
+ return bool(self.pip_name) and shutil.which(self.pip_name) is not None
113
+ if t == "git":
114
+ return bool(self.git_url) and _git_clone_dest(self.git_url).exists()
115
+ if t == "apt":
116
+ return bool(self.apt_packages) and _apt_installed(self.apt_packages.split()[0])
117
+ return False
118
+
119
+ def get_version(self) -> str:
120
+ cmd = self.check_cmd or _type_cmd(self)
121
+ if not cmd or not shutil.which(cmd):
122
+ return ""
123
+ for flag in ["--version", "-v", "version"]:
124
+ try:
125
+ r = subprocess.run([cmd, flag], capture_output=True, text=True, timeout=5)
126
+ if r.returncode == 0 and r.stdout.strip():
127
+ return r.stdout.strip().splitlines()[0]
128
+ except Exception:
129
+ pass
130
+ return "installed"
131
+
132
+ def install(self) -> Optional[str]:
133
+ from dev_setup import ui
134
+ t = self.install_type
135
+
136
+ if t == "npm":
137
+ if not self.npm_name:
138
+ raise RuntimeError("npm_name not set")
139
+ with ui.spinner(f"Installing {self.name} via npm..."):
140
+ subprocess.run(
141
+ ["npm", "install", "-g", self.npm_name],
142
+ check=True, capture_output=True,
143
+ )
144
+ elif t == "pip":
145
+ if not self.pip_name:
146
+ raise RuntimeError("pip_name not set")
147
+ uv = shutil.which("uv")
148
+ with ui.spinner(f"Installing {self.name} via pip..."):
149
+ if uv:
150
+ subprocess.run(
151
+ [uv, "tool", "install", self.pip_name],
152
+ check=True, capture_output=True,
153
+ )
154
+ else:
155
+ subprocess.run(
156
+ ["pip3", "install", "--user", self.pip_name],
157
+ check=True, capture_output=True,
158
+ )
159
+ elif t == "git":
160
+ if not self.git_url:
161
+ raise RuntimeError("git_url not set")
162
+ dest = _git_clone_dest(self.git_url)
163
+ with ui.spinner(f"Cloning {self.name}..."):
164
+ subprocess.run(
165
+ ["git", "clone", "--depth=1", self.git_url, str(dest)],
166
+ check=True, capture_output=True,
167
+ )
168
+ if self.git_install_cmd:
169
+ with ui.spinner(f"Running install command..."):
170
+ subprocess.run(
171
+ ["bash", "-c", self.git_install_cmd],
172
+ cwd=dest, check=True, capture_output=True,
173
+ )
174
+ elif t == "apt":
175
+ if not self.apt_packages:
176
+ raise RuntimeError("apt_packages not set")
177
+ with ui.spinner(f"Installing {self.name} via apt..."):
178
+ subprocess.run(
179
+ ["sudo", "apt-get", "install", "-y"] + self.apt_packages.split(),
180
+ check=True, capture_output=True,
181
+ )
182
+ elif t == "script":
183
+ if not self.script_url:
184
+ raise RuntimeError("script_url not set")
185
+ with ui.spinner(f"Running install script for {self.name}..."):
186
+ subprocess.run(
187
+ ["bash", "-c", f"curl -fsSL '{self.script_url}' | sh"],
188
+ check=True, capture_output=True,
189
+ )
190
+ elif t == "bash":
191
+ if not self.install_script:
192
+ raise RuntimeError("install_script not set")
193
+ with ui.spinner(f"Installing {self.name}..."):
194
+ _run_bash_script(self.install_script)
195
+ else:
196
+ raise RuntimeError(f"Unsupported install type: {t!r}")
197
+
198
+ return self.get_version() or None
199
+
200
+ def remove(self) -> None:
201
+ from dev_setup import ui
202
+ t = self.install_type
203
+
204
+ if t == "npm":
205
+ with ui.spinner(f"Removing {self.name}..."):
206
+ subprocess.run(
207
+ ["npm", "uninstall", "-g", self.npm_name],
208
+ check=True, capture_output=True,
209
+ )
210
+ elif t == "pip":
211
+ uv = shutil.which("uv")
212
+ with ui.spinner(f"Removing {self.name}..."):
213
+ if uv:
214
+ subprocess.run(
215
+ [uv, "tool", "uninstall", self.pip_name],
216
+ check=True, capture_output=True,
217
+ )
218
+ else:
219
+ subprocess.run(
220
+ ["pip3", "uninstall", "-y", self.pip_name],
221
+ check=True, capture_output=True,
222
+ )
223
+ elif t == "git":
224
+ dest = _git_clone_dest(self.git_url)
225
+ if self.git_remove_cmd:
226
+ with ui.spinner(f"Running remove command..."):
227
+ subprocess.run(
228
+ ["bash", "-c", self.git_remove_cmd],
229
+ cwd=dest, capture_output=True,
230
+ )
231
+ import shutil as _shutil
232
+ if dest.exists():
233
+ _shutil.rmtree(dest)
234
+ elif t == "apt":
235
+ with ui.spinner(f"Removing {self.name}..."):
236
+ subprocess.run(
237
+ ["sudo", "apt-get", "remove", "-y"] + self.apt_packages.split(),
238
+ check=True, capture_output=True,
239
+ )
240
+ elif t == "script":
241
+ raise RuntimeError(
242
+ "Script-type packages cannot be auto-removed. "
243
+ "Remove manually then run: dev-setup delete " + self.key
244
+ )
245
+ elif t == "bash":
246
+ if not self.remove_script:
247
+ raise RuntimeError(
248
+ f"No remove script defined for '{self.key}'. "
249
+ "Remove manually then run: dev-setup delete " + self.key
250
+ )
251
+ with ui.spinner(f"Removing {self.name}..."):
252
+ _run_bash_script(self.remove_script)
253
+ else:
254
+ raise RuntimeError(f"Unsupported remove type: {t!r}")
255
+
256
+
257
+ def _npm_global_installed(pkg: str) -> bool:
258
+ try:
259
+ r = subprocess.run(["npm", "list", "-g", "--depth=0", pkg], capture_output=True, text=True)
260
+ return pkg in r.stdout
261
+ except Exception:
262
+ return False
263
+
264
+
265
+ def _apt_installed(pkg: str) -> bool:
266
+ try:
267
+ r = subprocess.run(["dpkg", "-s", pkg], capture_output=True, text=True)
268
+ return "Status: install ok installed" in r.stdout
269
+ except Exception:
270
+ return False
271
+
272
+
273
+ def _git_clone_dest(url: str) -> Path:
274
+ repo_name = url.rstrip("/").split("/")[-1].removesuffix(".git")
275
+ return Path.home() / ".local" / "share" / "dev-setup" / repo_name
276
+
277
+
278
+ def _run_bash_script(script: str) -> None:
279
+ """Write script to a temp file and execute it with bash, capturing output."""
280
+ import os
281
+ import tempfile
282
+
283
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
284
+ f.write(script)
285
+ tmp = f.name
286
+ try:
287
+ result = subprocess.run(["bash", tmp], capture_output=True, text=True)
288
+ if result.returncode != 0:
289
+ raise RuntimeError(
290
+ result.stderr.strip() or f"Script exited with code {result.returncode}"
291
+ )
292
+ finally:
293
+ os.unlink(tmp)
294
+
295
+
296
+ def _type_cmd(tool: GenericTool) -> str:
297
+ t = tool.install_type
298
+ if t == "npm":
299
+ return tool.npm_name
300
+ if t == "pip":
301
+ return tool.pip_name
302
+ return ""
File without changes
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import zipfile
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from dev_setup.base import Tool
12
+
13
+ _AWS_CLI_DIR = Path("/usr/local/aws-cli")
14
+ _AWS_BIN = Path("/usr/local/bin/aws")
15
+
16
+
17
+ class AwsCliTool(Tool):
18
+ key = "aws"
19
+ name = "AWS CLI"
20
+ description = "Amazon Web Services command line interface (v2)"
21
+ category = "tools"
22
+ install_type = "script"
23
+ help_cmd = "aws help"
24
+
25
+ def is_installed(self) -> bool:
26
+ return shutil.which("aws") is not None
27
+
28
+ def get_version(self) -> str:
29
+ r = subprocess.run(["aws", "--version"], capture_output=True, text=True)
30
+ return r.stdout.strip() or r.stderr.strip()
31
+
32
+ def install(self) -> Optional[str]:
33
+ from dev_setup import ui
34
+
35
+ arch = "aarch64" if platform.machine() == "aarch64" else "x86_64"
36
+ url = f"https://awscli.amazonaws.com/awscli-exe-linux-{arch}.zip"
37
+
38
+ with tempfile.TemporaryDirectory() as tmpdir:
39
+ zip_path = Path(tmpdir) / "awscliv2.zip"
40
+
41
+ with ui.spinner("Downloading AWS CLI v2..."):
42
+ subprocess.run(
43
+ ["curl", "-fsSL", url, "-o", str(zip_path)],
44
+ check=True, capture_output=True,
45
+ )
46
+
47
+ with ui.spinner("Extracting AWS CLI..."):
48
+ with zipfile.ZipFile(zip_path) as zf:
49
+ zf.extractall(tmpdir)
50
+
51
+ with ui.spinner("Installing AWS CLI (sudo)..."):
52
+ subprocess.run(
53
+ ["sudo", str(Path(tmpdir) / "aws" / "install")],
54
+ check=True, capture_output=True,
55
+ )
56
+
57
+ if not self.is_installed():
58
+ raise RuntimeError("AWS CLI installation failed — aws binary not found")
59
+ return self.get_version()
60
+
61
+ def remove(self) -> None:
62
+ from dev_setup import ui
63
+
64
+ with ui.spinner("Removing AWS CLI..."):
65
+ for path in [
66
+ _AWS_CLI_DIR,
67
+ _AWS_BIN,
68
+ Path("/usr/local/bin/aws_completer"),
69
+ ]:
70
+ if path.is_dir():
71
+ subprocess.run(["sudo", "rm", "-rf", str(path)], check=True, capture_output=True)
72
+ elif path.exists():
73
+ subprocess.run(["sudo", "rm", "-f", str(path)], check=True, capture_output=True)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from dev_setup.base import Tool
11
+
12
+
13
+ class DockerTool(Tool):
14
+ key = "docker"
15
+ name = "Docker"
16
+ description = "Container runtime + docker compose plugin"
17
+ category = "core"
18
+ install_type = "script"
19
+ help_cmd = "docker --help"
20
+
21
+ def is_installed(self) -> bool:
22
+ return shutil.which("docker") is not None
23
+
24
+ def get_version(self) -> str:
25
+ r = subprocess.run(["docker", "--version"], capture_output=True, text=True)
26
+ return r.stdout.strip() if r.returncode == 0 else ""
27
+
28
+ def install(self) -> Optional[str]:
29
+ from dev_setup import ui
30
+
31
+ tmp = tempfile.NamedTemporaryFile(suffix=".sh", delete=False)
32
+ tmp.close()
33
+ try:
34
+ with ui.spinner("Downloading Docker install script..."):
35
+ subprocess.run(
36
+ ["curl", "-fsSL", "https://get.docker.com", "-o", tmp.name],
37
+ check=True, capture_output=True,
38
+ )
39
+ with ui.spinner("Installing Docker (this may take a minute)..."):
40
+ subprocess.run(["sudo", "sh", tmp.name], check=True, capture_output=True)
41
+ finally:
42
+ Path(tmp.name).unlink(missing_ok=True)
43
+
44
+ user = os.environ.get("USER", "")
45
+ if user:
46
+ r = subprocess.run(["groups", user], capture_output=True, text=True)
47
+ if "docker" not in r.stdout:
48
+ ui.info(f"Adding {user} to docker group...")
49
+ subprocess.run(
50
+ ["sudo", "usermod", "-aG", "docker", user],
51
+ check=True, capture_output=True,
52
+ )
53
+ ui.warn("Log out and back in for docker group to take effect")
54
+
55
+ subprocess.run(["sudo", "systemctl", "enable", "docker"], capture_output=True)
56
+ subprocess.run(["sudo", "systemctl", "start", "docker"], capture_output=True)
57
+ self._ensure_compose(ui)
58
+
59
+ if not self.is_installed():
60
+ raise RuntimeError("Docker installation failed — docker binary not found")
61
+ return self.get_version()
62
+
63
+ def _ensure_compose(self, ui) -> None: # type: ignore[no-untyped-def]
64
+ r = subprocess.run(["docker", "compose", "version"], capture_output=True)
65
+ if r.returncode == 0:
66
+ return
67
+ ui.info("Installing docker-compose-plugin...")
68
+ for mgr_cmd in [
69
+ ["sudo", "apt-get", "install", "-y", "docker-compose-plugin"],
70
+ ["sudo", "yum", "install", "-y", "docker-compose-plugin"],
71
+ ["sudo", "dnf", "install", "-y", "docker-compose-plugin"],
72
+ ]:
73
+ r = subprocess.run(mgr_cmd, capture_output=True)
74
+ if r.returncode == 0:
75
+ return
76
+ ui.warn("docker-compose-plugin not available via package manager")
77
+
78
+ def remove(self) -> None:
79
+ from dev_setup import ui
80
+
81
+ with ui.spinner("Stopping Docker service..."):
82
+ subprocess.run(["sudo", "systemctl", "stop", "docker"], capture_output=True)
83
+ subprocess.run(["sudo", "systemctl", "disable", "docker"], capture_output=True)
84
+
85
+ with ui.spinner("Removing Docker packages..."):
86
+ subprocess.run(
87
+ ["bash", "-c",
88
+ "sudo apt-get remove -y docker-ce docker-ce-cli containerd.io "
89
+ "docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras "
90
+ "2>/dev/null || sudo yum remove -y docker-ce docker-ce-cli containerd.io "
91
+ "docker-compose-plugin 2>/dev/null || true"],
92
+ capture_output=True,
93
+ )
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ from typing import Optional
6
+
7
+ from dev_setup.base import Tool
8
+
9
+
10
+ class HtopTool(Tool):
11
+ key = "htop"
12
+ name = "htop"
13
+ description = "Interactive process and resource monitor"
14
+ category = "tools"
15
+ install_type = "apt"
16
+ help_cmd = "man htop"
17
+
18
+ def is_installed(self) -> bool:
19
+ return shutil.which("htop") is not None
20
+
21
+ def get_version(self) -> str:
22
+ r = subprocess.run(["htop", "--version"], capture_output=True, text=True)
23
+ return r.stdout.strip().splitlines()[0] if r.returncode == 0 else ""
24
+
25
+ def install(self) -> Optional[str]:
26
+ from dev_setup import ui
27
+
28
+ with ui.spinner("Installing htop..."):
29
+ result = subprocess.run(
30
+ ["bash", "-c",
31
+ "sudo apt-get install -y htop 2>/dev/null || "
32
+ "sudo yum install -y htop 2>/dev/null || "
33
+ "sudo dnf install -y htop 2>/dev/null || "
34
+ "sudo pacman -S --noconfirm htop 2>/dev/null"],
35
+ capture_output=True,
36
+ )
37
+
38
+ if not self.is_installed():
39
+ raise RuntimeError("htop installation failed — no supported package manager found")
40
+ return self.get_version()
41
+
42
+ def remove(self) -> None:
43
+ from dev_setup import ui
44
+
45
+ with ui.spinner("Removing htop..."):
46
+ subprocess.run(
47
+ ["bash", "-c",
48
+ "sudo apt-get remove -y htop 2>/dev/null || "
49
+ "sudo yum remove -y htop 2>/dev/null || "
50
+ "sudo dnf remove -y htop 2>/dev/null || "
51
+ "sudo pacman -R --noconfirm htop 2>/dev/null"],
52
+ capture_output=True,
53
+ )
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import urllib.request
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from dev_setup.base import Tool, patch_bashrc, remove_bashrc_block
10
+
11
+ NVM_DIR = Path.home() / ".nvm"
12
+ NVM_INIT_BLOCK = "nvm"
13
+ NVM_INIT_LINES = (
14
+ 'export NVM_DIR="$HOME/.nvm"\n'
15
+ '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"\n'
16
+ '[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"'
17
+ )
18
+
19
+
20
+ class NvmTool(Tool):
21
+ key = "nvm"
22
+ name = "NVM + Node LTS"
23
+ description = "Node Version Manager + latest Node LTS"
24
+ category = "core"
25
+ install_type = "script"
26
+ help_cmd = "nvm help"
27
+
28
+ def is_installed(self) -> bool:
29
+ return (NVM_DIR / "nvm.sh").exists()
30
+
31
+ def get_version(self) -> str:
32
+ r = subprocess.run(
33
+ ["bash", "-c", f'. "{NVM_DIR}/nvm.sh" && nvm --version'],
34
+ capture_output=True, text=True,
35
+ )
36
+ return f"nvm v{r.stdout.strip()}" if r.returncode == 0 else ""
37
+
38
+ def install(self) -> Optional[str]:
39
+ from dev_setup import ui
40
+
41
+ tag = self._latest_release_tag()
42
+ url = f"https://raw.githubusercontent.com/nvm-sh/nvm/{tag}/install.sh"
43
+
44
+ with ui.spinner(f"Installing NVM {tag}..."):
45
+ subprocess.run(
46
+ ["bash", "-c", f"curl -o- '{url}' | bash"],
47
+ check=True, capture_output=True,
48
+ )
49
+
50
+ if not self.is_installed():
51
+ raise RuntimeError("NVM installation failed — nvm.sh not found")
52
+
53
+ patch_bashrc(NVM_INIT_BLOCK, NVM_INIT_LINES)
54
+ ui.success("NVM init added to ~/.bashrc")
55
+
56
+ with ui.spinner("Installing Node LTS..."):
57
+ subprocess.run(
58
+ ["bash", "-c", f'. "{NVM_DIR}/nvm.sh" && nvm install --lts'],
59
+ check=True, capture_output=True,
60
+ )
61
+
62
+ return self.get_version()
63
+
64
+ def remove(self) -> None:
65
+ from dev_setup import ui
66
+
67
+ if NVM_DIR.exists():
68
+ import shutil as _shutil
69
+ with ui.spinner("Removing NVM directory..."):
70
+ _shutil.rmtree(NVM_DIR)
71
+
72
+ remove_bashrc_block(NVM_INIT_BLOCK)
73
+ ui.info("NVM init removed from ~/.bashrc")
74
+
75
+ @staticmethod
76
+ def _latest_release_tag() -> str:
77
+ try:
78
+ url = "https://api.github.com/repos/nvm-sh/nvm/releases/latest"
79
+ req = urllib.request.Request(url, headers={"User-Agent": "dev-setup"})
80
+ with urllib.request.urlopen(req, timeout=10) as resp:
81
+ data = json.loads(resp.read())
82
+ return data["tag_name"]
83
+ except Exception:
84
+ return "v0.40.3"
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ from typing import Optional
6
+
7
+ from dev_setup.base import Tool
8
+
9
+
10
+ class PhpTool(Tool):
11
+ key = "php"
12
+ name = "PHP 8.4"
13
+ description = "PHP 8.4 + common extensions via ondrej/php PPA"
14
+ category = "tools"
15
+ install_type = "apt"
16
+ help_cmd = "php --help"
17
+
18
+ def is_installed(self) -> bool:
19
+ return shutil.which("php") is not None
20
+
21
+ def get_version(self) -> str:
22
+ r = subprocess.run(["php", "--version"], capture_output=True, text=True)
23
+ return r.stdout.strip().splitlines()[0] if r.returncode == 0 else ""
24
+
25
+ def install(self) -> Optional[str]:
26
+ from dev_setup import ui
27
+
28
+ if not shutil.which("add-apt-repository"):
29
+ with ui.spinner("Installing software-properties-common..."):
30
+ subprocess.run(
31
+ ["sudo", "apt-get", "install", "-y", "software-properties-common"],
32
+ check=True, capture_output=True,
33
+ )
34
+
35
+ with ui.spinner("Adding ondrej/php PPA..."):
36
+ subprocess.run(
37
+ ["sudo", "add-apt-repository", "-y", "ppa:ondrej/php"],
38
+ check=True, capture_output=True,
39
+ )
40
+
41
+ with ui.spinner("Updating package index..."):
42
+ subprocess.run(["sudo", "apt-get", "update", "-q"], check=True, capture_output=True)
43
+
44
+ packages = [
45
+ "php8.4", "php8.4-cli", "php8.4-common",
46
+ "php8.4-curl", "php8.4-mbstring", "php8.4-xml", "php8.4-zip",
47
+ ]
48
+ with ui.spinner("Installing PHP 8.4 + extensions..."):
49
+ subprocess.run(
50
+ ["sudo", "apt-get", "install", "-y"] + packages,
51
+ check=True, capture_output=True,
52
+ )
53
+
54
+ if not self.is_installed():
55
+ raise RuntimeError("PHP installation failed")
56
+ return self.get_version()
57
+
58
+ def remove(self) -> None:
59
+ from dev_setup import ui
60
+
61
+ with ui.spinner("Removing PHP 8.4 packages..."):
62
+ subprocess.run(
63
+ ["bash", "-c", "sudo apt-get remove -y 'php8.4*' && sudo apt-get autoremove -y"],
64
+ check=True, capture_output=True,
65
+ )