net-install-manager 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. net_install_manager-0.1.0/LICENSE +21 -0
  2. net_install_manager-0.1.0/PKG-INFO +105 -0
  3. net_install_manager-0.1.0/README.md +80 -0
  4. net_install_manager-0.1.0/pyproject.toml +63 -0
  5. net_install_manager-0.1.0/src/net_install_manager/__init__.py +17 -0
  6. net_install_manager-0.1.0/src/net_install_manager/app_manager.py +339 -0
  7. net_install_manager-0.1.0/src/net_install_manager/cli_output.py +23 -0
  8. net_install_manager-0.1.0/src/net_install_manager/commands/__init__.py +0 -0
  9. net_install_manager-0.1.0/src/net_install_manager/commands/app_details.py +59 -0
  10. net_install_manager-0.1.0/src/net_install_manager/commands/install.py +114 -0
  11. net_install_manager-0.1.0/src/net_install_manager/commands/install_release.py +142 -0
  12. net_install_manager-0.1.0/src/net_install_manager/commands/list_apps.py +37 -0
  13. net_install_manager-0.1.0/src/net_install_manager/commands/list_versions.py +68 -0
  14. net_install_manager-0.1.0/src/net_install_manager/commands/rollback.py +59 -0
  15. net_install_manager-0.1.0/src/net_install_manager/commands/uninstall.py +71 -0
  16. net_install_manager-0.1.0/src/net_install_manager/commands/upgrade.py +272 -0
  17. net_install_manager-0.1.0/src/net_install_manager/config.py +124 -0
  18. net_install_manager-0.1.0/src/net_install_manager/errors.py +35 -0
  19. net_install_manager-0.1.0/src/net_install_manager/net_install_manager.py +268 -0
  20. net_install_manager-0.1.0/src/net_install_manager/paths.py +73 -0
  21. net_install_manager-0.1.0/src/net_install_manager/registry.py +176 -0
  22. net_install_manager-0.1.0/src/net_install_manager/runtime_config.py +59 -0
  23. net_install_manager-0.1.0/src/net_install_manager/utilities/__init__.py +0 -0
  24. net_install_manager-0.1.0/src/net_install_manager/utilities/csproj_tools.py +112 -0
  25. net_install_manager-0.1.0/src/net_install_manager/utilities/git_tools.py +219 -0
  26. net_install_manager-0.1.0/src/net_install_manager/utilities/github_release_tools.py +202 -0
  27. net_install_manager-0.1.0/src/net_install_manager/utilities/install_tools.py +189 -0
  28. net_install_manager-0.1.0/src/net_install_manager/utilities/os_ops.py +325 -0
  29. net_install_manager-0.1.0/src/net_install_manager/utilities/resolve_manager.py +33 -0
  30. net_install_manager-0.1.0/src/net_install_manager/utilities/sh.py +84 -0
  31. net_install_manager-0.1.0/src/net_install_manager/utilities/sys_platform.py +133 -0
  32. net_install_manager-0.1.0/src/net_install_manager/utilities/version_tools.py +117 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas C. Flanagan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: net-install-manager
3
+ Version: 0.1.0
4
+ Summary: Simple tool for managing installations of .NET console applications
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: dotnet,installation,package-management,cli
8
+ Author: Thomas C. Flanagan
9
+ Author-email: t@tcflanagan.net
10
+ Requires-Python: >=3.12
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Development Status :: 3 - Alpha
18
+ Requires-Dist: pyyaml (>=6.0.3,<7.0.0)
19
+ Requires-Dist: requests (>=2.34.2,<3.0.0)
20
+ Requires-Dist: semver (>=3.1.0,<4.0.0)
21
+ Project-URL: Homepage, https://github.com/tcfoss/net-install-manager
22
+ Project-URL: Repository, https://github.com/tcfoss/net-install-manager.git
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Net Install Manager (`ninman`)
26
+
27
+ A CLI tool (installable via `pipx`) for managing installations, rollbacks, versioning, and uninstallation of .NET console applications across Linux, macOS, and Windows.
28
+
29
+ ## Features
30
+
31
+ - **Source, Pre-compiled & Git Support**: Install directly from `.csproj` source, pre-compiled binaries (DLLs/EXEs), or remote Git/GitHub repositories.
32
+ - **Cross-Platform**:
33
+ - **Linux / macOS**: Installs to `~/.local/lib/<app>/<version>` (or `/usr/local/lib` for system), atomic `current` symlinks, and launchers in `~/.local/bin`.
34
+ - **Windows**: Installs to `%LOCALAPPDATA%\<app>\versions\<version>` (or `Program Files` for system), `current` directory junctions, and `.cmd` + bash launchers in `bin/`.
35
+ - **Version Management & Rollback**: Automatic version detection (csproj, git tags, binary `--version`), version regression checks, rollback to previous versions, and version pruning (`--versions-to-keep`).
36
+ - **Application Registry & Upgrades**: Track installed applications and upgrade them directly (`ninman upgrade <app>` or `ninman upgrade --all`), including fetching the latest Git commits/tags.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pipx install net-install-manager
42
+ ```
43
+
44
+ ## CLI Usage
45
+
46
+ ```bash
47
+ # Install from a local .csproj file or current directory
48
+ ninman install
49
+ ninman install ./src/MyApp/MyApp.csproj
50
+ ninman install ./dist/MyApp-v1.2.0
51
+
52
+ # Install directly from GitHub / Git repository
53
+ ninman install https://github.com/owner/repo.git
54
+ ninman install owner/repo
55
+ ninman install owner/repo@v1.2.0
56
+
57
+ # Install a prebuilt asset from a GitHub release
58
+ ninman install-release owner repo --asset-pattern "{rid}-{version}"
59
+
60
+ # Multi-project repositories: select a specific project via subpath or --project
61
+ ninman install https://github.com/owner/repo.git --project src/CliApp/CliApp.csproj
62
+ ninman install https://github.com/owner/repo/tree/main/src/CliApp
63
+ ninman install owner/repo#src/CliApp
64
+
65
+ # Install as self-contained with version pruning
66
+ ninman install --self-contained --versions-to-keep 3
67
+
68
+ # Force installation / reinstallation of older version
69
+ ninman install --force --app-version 1.0.0
70
+
71
+ # List installed versions
72
+ ninman list-versions
73
+
74
+ # List all tracked applications managed by ninman
75
+ ninman list-apps
76
+
77
+ # Upgrade an application or all tracked applications
78
+ ninman upgrade
79
+ ninman upgrade myapp
80
+ ninman upgrade --all
81
+
82
+ # Rollback to the previous version
83
+ ninman rollback --previous
84
+
85
+ # Rollback to a specific version
86
+ ninman rollback --target-version 1.1.0
87
+
88
+ # Uninstall a specific version or everything
89
+ ninman uninstall --target-version 1.0.0
90
+ ninman uninstall --all
91
+ ```
92
+
93
+ GitHub release installations discover `ninman.yml`, `ninman.yaml`, or a `.csproj` from the
94
+ unpacked release asset. Private repositories can use `NINMAN_GITHUB_TOKEN`, `GITHUB_TOKEN`, or
95
+ `GH_TOKEN`; tokens are used for requests only and are not stored in the application registry.
96
+
97
+ ## Configuration (`ninman.yaml`)
98
+
99
+ ```yaml
100
+ app_directory: my-app
101
+ binary_name: myapp
102
+ source_binary_name: MyApp.dll # or MyApp.exe
103
+ csproj_path: src/MyApp/MyApp.csproj # optional
104
+ ```
105
+
@@ -0,0 +1,80 @@
1
+ # Net Install Manager (`ninman`)
2
+
3
+ A CLI tool (installable via `pipx`) for managing installations, rollbacks, versioning, and uninstallation of .NET console applications across Linux, macOS, and Windows.
4
+
5
+ ## Features
6
+
7
+ - **Source, Pre-compiled & Git Support**: Install directly from `.csproj` source, pre-compiled binaries (DLLs/EXEs), or remote Git/GitHub repositories.
8
+ - **Cross-Platform**:
9
+ - **Linux / macOS**: Installs to `~/.local/lib/<app>/<version>` (or `/usr/local/lib` for system), atomic `current` symlinks, and launchers in `~/.local/bin`.
10
+ - **Windows**: Installs to `%LOCALAPPDATA%\<app>\versions\<version>` (or `Program Files` for system), `current` directory junctions, and `.cmd` + bash launchers in `bin/`.
11
+ - **Version Management & Rollback**: Automatic version detection (csproj, git tags, binary `--version`), version regression checks, rollback to previous versions, and version pruning (`--versions-to-keep`).
12
+ - **Application Registry & Upgrades**: Track installed applications and upgrade them directly (`ninman upgrade <app>` or `ninman upgrade --all`), including fetching the latest Git commits/tags.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pipx install net-install-manager
18
+ ```
19
+
20
+ ## CLI Usage
21
+
22
+ ```bash
23
+ # Install from a local .csproj file or current directory
24
+ ninman install
25
+ ninman install ./src/MyApp/MyApp.csproj
26
+ ninman install ./dist/MyApp-v1.2.0
27
+
28
+ # Install directly from GitHub / Git repository
29
+ ninman install https://github.com/owner/repo.git
30
+ ninman install owner/repo
31
+ ninman install owner/repo@v1.2.0
32
+
33
+ # Install a prebuilt asset from a GitHub release
34
+ ninman install-release owner repo --asset-pattern "{rid}-{version}"
35
+
36
+ # Multi-project repositories: select a specific project via subpath or --project
37
+ ninman install https://github.com/owner/repo.git --project src/CliApp/CliApp.csproj
38
+ ninman install https://github.com/owner/repo/tree/main/src/CliApp
39
+ ninman install owner/repo#src/CliApp
40
+
41
+ # Install as self-contained with version pruning
42
+ ninman install --self-contained --versions-to-keep 3
43
+
44
+ # Force installation / reinstallation of older version
45
+ ninman install --force --app-version 1.0.0
46
+
47
+ # List installed versions
48
+ ninman list-versions
49
+
50
+ # List all tracked applications managed by ninman
51
+ ninman list-apps
52
+
53
+ # Upgrade an application or all tracked applications
54
+ ninman upgrade
55
+ ninman upgrade myapp
56
+ ninman upgrade --all
57
+
58
+ # Rollback to the previous version
59
+ ninman rollback --previous
60
+
61
+ # Rollback to a specific version
62
+ ninman rollback --target-version 1.1.0
63
+
64
+ # Uninstall a specific version or everything
65
+ ninman uninstall --target-version 1.0.0
66
+ ninman uninstall --all
67
+ ```
68
+
69
+ GitHub release installations discover `ninman.yml`, `ninman.yaml`, or a `.csproj` from the
70
+ unpacked release asset. Private repositories can use `NINMAN_GITHUB_TOKEN`, `GITHUB_TOKEN`, or
71
+ `GH_TOKEN`; tokens are used for requests only and are not stored in the application registry.
72
+
73
+ ## Configuration (`ninman.yaml`)
74
+
75
+ ```yaml
76
+ app_directory: my-app
77
+ binary_name: myapp
78
+ source_binary_name: MyApp.dll # or MyApp.exe
79
+ csproj_path: src/MyApp/MyApp.csproj # optional
80
+ ```
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "net-install-manager"
3
+ description = "Simple tool for managing installations of .NET console applications"
4
+ authors = [
5
+ {name = "Thomas C. Flanagan",email = "t@tcflanagan.net"}
6
+ ]
7
+ readme = "README.md"
8
+ requires-python = ">=3.12"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: System Administrators",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Operating System :: OS Independent",
18
+ "Development Status :: 3 - Alpha",
19
+ ]
20
+ keywords = ["dotnet", "installation", "package-management", "cli"]
21
+ dependencies = [
22
+ "pyyaml (>=6.0.3,<7.0.0)",
23
+ "semver (>=3.1.0,<4.0.0)",
24
+ "requests (>=2.34.2,<3.0.0)"
25
+ ]
26
+ dynamic = []
27
+ version = "0.1.0"
28
+
29
+ [project.urls]
30
+ homepage = "https://github.com/tcfoss/net-install-manager"
31
+ repository = "https://github.com/tcfoss/net-install-manager.git"
32
+
33
+ [project.scripts]
34
+ ninman = "net_install_manager.net_install_manager:main"
35
+
36
+ [tool.poetry]
37
+ packages = [{include = "net_install_manager", from = "src"}]
38
+
39
+ [tool.poetry.requires-plugins]
40
+ poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
41
+
42
+ [tool.poetry-dynamic-versioning]
43
+ enable = false
44
+
45
+ [tool.mypy]
46
+ mypy_path = "src"
47
+
48
+ [build-system]
49
+ requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
50
+ build-backend = "poetry_dynamic_versioning.backend"
51
+
52
+ [dependency-groups]
53
+ dev = [
54
+ "pytest (>=9.1.1,<10.0.0)",
55
+ "pylint (>=4.0.8,<5.0.0)",
56
+ "pytest-cov (>=7.1.0,<8.0.0)",
57
+ "mypy (>=2.3.1,<3.0.0)",
58
+ "types-pyyaml (>=6.0.12.20260906,<7.0.0.0)"
59
+ ]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+
@@ -0,0 +1,17 @@
1
+ """Net Install Manager (`ninman`) package."""
2
+
3
+ from net_install_manager.config import Config, load_config, parse_config, auto_discover_config
4
+ from net_install_manager.paths import Paths, get_paths
5
+ from net_install_manager.app_manager import AppManager, AppManagerError, VersionRegressionError
6
+
7
+ __all__ = [
8
+ "Config",
9
+ "load_config",
10
+ "parse_config",
11
+ "auto_discover_config",
12
+ "Paths",
13
+ "get_paths",
14
+ "AppManager",
15
+ "AppManagerError",
16
+ "VersionRegressionError",
17
+ ]
@@ -0,0 +1,339 @@
1
+ """Unified application manager for installing and managing .NET applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import shutil
7
+ import logging
8
+ import semver
9
+
10
+ from net_install_manager.config import Config
11
+ from net_install_manager.paths import get_paths
12
+ from net_install_manager.registry import AppRegistry, TrackedApp
13
+ from net_install_manager.runtime_config import InstallOptions, RuntimeInfo
14
+ from net_install_manager.utilities.sys_platform import PlatformInfo, OperatingSystem
15
+ from net_install_manager.utilities.os_ops import (
16
+ OsOpsProtocol,
17
+ get_os_ops,
18
+ )
19
+ from net_install_manager.utilities.csproj_tools import (
20
+ publish as dotnet_publish,
21
+ )
22
+ from net_install_manager.utilities.version_tools import (
23
+ parse_semver,
24
+ resolve_version,
25
+ )
26
+ from net_install_manager.utilities.install_tools import (
27
+ InstallSource,
28
+ get_buildable_source,
29
+ get_paths_no_buildable_source,
30
+ get_paths_with_buildable_source,
31
+ )
32
+ from net_install_manager.errors import AppManagerError, VersionRegressionError
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class AppManager:
38
+ """Manages the installation, rollback, uninstallation, and listing of a .NET application."""
39
+
40
+ def __init__(
41
+ self,
42
+ config: Config,
43
+ prefer_opt: bool = False,
44
+ runtime: RuntimeInfo | None = None,
45
+ os_ops: type[OsOpsProtocol] | None = None,
46
+ registry: AppRegistry | None = None,
47
+ ):
48
+ self.config = config
49
+ self.runtime = runtime or RuntimeInfo.current()
50
+ self.os_ops = os_ops or get_os_ops(self.runtime.os)
51
+ self.registry = registry or AppRegistry(runtime=self.runtime)
52
+ self.paths = get_paths(
53
+ config=self.config,
54
+ prefer_opt=prefer_opt,
55
+ runtime=self.runtime,
56
+ )
57
+
58
+ def with_config(self, config: Config) -> AppManager:
59
+ """Return a new AppManager instance with the given configuration."""
60
+ return AppManager(
61
+ config=config,
62
+ prefer_opt=self.prefer_opt,
63
+ runtime=self.runtime,
64
+ os_ops=self.os_ops,
65
+ registry=self.registry,
66
+ )
67
+
68
+ @property
69
+ def prefer_opt(self) -> bool:
70
+ """Return whether target paths use /opt."""
71
+ return self.paths.lib_dir.as_posix().startswith("/opt/")
72
+
73
+ @property
74
+ def platform(self) -> PlatformInfo:
75
+ """Return platform info for this application manager."""
76
+ return self.runtime.platform
77
+
78
+ @property
79
+ def target_os(self) -> OperatingSystem:
80
+ """Return target operating system for backward compatibility."""
81
+ return self.runtime.os
82
+
83
+ def get_installed_versions(self) -> list[semver.Version]:
84
+ """Return a sorted list of installed versions (highest first)."""
85
+ if not self.paths.versions_dir.exists() or not self.paths.versions_dir.is_dir():
86
+ return []
87
+
88
+ versions: list[semver.Version] = []
89
+ for item in self.paths.versions_dir.iterdir():
90
+ if item.is_dir() and item.name != "current":
91
+ v = parse_semver(item.name)
92
+ if v is not None:
93
+ versions.append(v)
94
+
95
+ versions.sort(reverse=True)
96
+ return versions
97
+
98
+ def get_current_version(self) -> semver.Version | None:
99
+ """Get the version currently targeted by the 'current' link/junction."""
100
+ if not self.paths.current_dir.exists(follow_symlinks=False):
101
+ return None
102
+
103
+ try:
104
+ target = self.paths.current_dir.resolve()
105
+ return parse_semver(target.name)
106
+ except Exception: # pylint: disable=broad-except
107
+ return None
108
+
109
+ def install(
110
+ self,
111
+ source: InstallSource,
112
+ options: InstallOptions,
113
+ registry_source: InstallSource | None = None,
114
+ ) -> semver.Version:
115
+ """Install application from source (.csproj, git repo, or precompiled directory)."""
116
+ buildable_source = get_buildable_source(source=source, quiet=options.quiet)
117
+
118
+ temp_staging_dir: Path | None = None
119
+ copy_source: Path
120
+ binary_path: Path
121
+
122
+ try:
123
+ if buildable_source:
124
+ copy_source, binary_path, temp_staging_dir = get_paths_with_buildable_source(
125
+ binary_name=self.config.source_binary_name
126
+ )
127
+ logger.info(
128
+ "Publishing %s to %s...", buildable_source.csproj_path, temp_staging_dir
129
+ )
130
+ dotnet_publish(
131
+ csproj_path=buildable_source.csproj_path,
132
+ output_dir=temp_staging_dir,
133
+ opts=options.to_build_opts(platform=self.platform),
134
+ quiet=options.quiet,
135
+ )
136
+ else:
137
+ copy_source, binary_path = get_paths_no_buildable_source(
138
+ binary_name=self.config.source_binary_name,
139
+ source=source,
140
+ quiet=options.quiet,
141
+ )
142
+
143
+ version = resolve_version(
144
+ explicit_version=options.explicit_version,
145
+ csproj_path=buildable_source.csproj_path if buildable_source else None,
146
+ binary_path=binary_path,
147
+ source_dir=buildable_source.source_root if buildable_source else copy_source,
148
+ )
149
+
150
+ # Version regression check
151
+ installed_versions = self.get_installed_versions()
152
+ if not options.force and installed_versions and installed_versions[0] >= version:
153
+ raise VersionRegressionError(
154
+ f"Version {installed_versions[0]} is already installed, which is newer than "
155
+ f"or equal to the version being installed ({version}). Use --force to override."
156
+ )
157
+
158
+ target_version_dir = self.paths.versions_dir / str(version)
159
+
160
+ # Transfer files
161
+ logger.info("Transferring files to %s...", target_version_dir)
162
+ self.os_ops.mirror_directory(
163
+ src=copy_source,
164
+ dest=target_version_dir,
165
+ exclude_patterns=options.exclude_patterns,
166
+ )
167
+
168
+ # Executable & Permissions
169
+ executable_path = target_version_dir / self.config.source_binary_name
170
+ self.os_ops.make_executable(executable_path)
171
+ if self.config.target_permissions:
172
+ self.os_ops.set_permissions(
173
+ target_version_dir,
174
+ permissions=self.config.target_permissions,
175
+ )
176
+
177
+ # Update 'current' link
178
+ logger.info("Updating 'current' link...")
179
+ self.os_ops.update_current_link(
180
+ versions_dir=self.paths.versions_dir,
181
+ target_version=str(version),
182
+ current_dir=self.paths.current_dir,
183
+ )
184
+
185
+ # Update launcher in bin
186
+ target_current_exec = self.paths.current_dir / self.config.source_binary_name
187
+ logger.info("Updating binary launcher in %s...", self.paths.bin_dir)
188
+ self.os_ops.create_bin_launcher(
189
+ bin_dir=self.paths.bin_dir,
190
+ bin_name=self.config.binary_name,
191
+ target_executable=target_current_exec,
192
+ )
193
+
194
+ # Prune old versions if keep_latest specified
195
+ if options.keep_latest and options.keep_latest > 0:
196
+ self.prune_versions(options.keep_latest)
197
+
198
+ # Track in registry
199
+ try:
200
+ tracked = TrackedApp(
201
+ binary_name=self.config.binary_name,
202
+ app_directory=self.config.app_directory,
203
+ source_binary_name=self.config.source_binary_name,
204
+ source=registry_source
205
+ or (buildable_source.source if buildable_source else source),
206
+ lib_path=str(self.paths.lib_dir),
207
+ self_contained=options.self_contained,
208
+ release=options.release,
209
+ target_permissions=self.config.target_permissions,
210
+ installed_version=str(version),
211
+ )
212
+ self.registry.register(tracked)
213
+ except Exception as e: # pylint: disable=broad-except
214
+ logger.warning("Could not register app in registry: %s", e)
215
+
216
+ return version
217
+
218
+ finally:
219
+ if temp_staging_dir and temp_staging_dir.exists():
220
+ shutil.rmtree(temp_staging_dir, ignore_errors=True)
221
+
222
+ def prune_versions(self, keep_latest: int) -> list[semver.Version]:
223
+ """Prune older installed versions, keeping only the specified number of latest versions."""
224
+ installed = self.get_installed_versions()
225
+ if len(installed) <= keep_latest:
226
+ return []
227
+
228
+ removed = []
229
+ for v in installed[keep_latest:]:
230
+ version_dir = self.paths.versions_dir / str(v)
231
+ if version_dir.exists():
232
+ self.os_ops.remove_link_or_dir(version_dir)
233
+ removed.append(v)
234
+ return removed
235
+
236
+ def rollback(
237
+ self, target_version: str | None = None, previous: bool = False
238
+ ) -> semver.Version:
239
+ """Rollback to a previous installed version."""
240
+ installed = self.get_installed_versions()
241
+ if not installed:
242
+ raise AppManagerError("No installed versions found to rollback to.")
243
+
244
+ current = self.get_current_version()
245
+ selected_version: semver.Version | None = None
246
+
247
+ if previous:
248
+ # Find the version directly before the current version
249
+ if current and len(installed) > 1:
250
+ for idx, v in enumerate(installed):
251
+ if v == current and idx + 1 < len(installed):
252
+ selected_version = installed[idx + 1]
253
+ break
254
+ if not selected_version and len(installed) > 1:
255
+ selected_version = installed[1]
256
+ elif not selected_version:
257
+ raise AppManagerError(
258
+ "Cannot rollback with --previous: only one version is installed."
259
+ )
260
+ elif target_version:
261
+ target_v = parse_semver(target_version)
262
+ if not target_v:
263
+ raise AppManagerError(f"Invalid semver version: '{target_version}'")
264
+ if target_v not in installed:
265
+ raise AppManagerError(f"Target version '{target_version}' is not installed.")
266
+ selected_version = target_v
267
+ else:
268
+ raise AppManagerError("Must specify either a target version or --previous.")
269
+
270
+ # Switch current link
271
+ self.os_ops.update_current_link(
272
+ versions_dir=self.paths.versions_dir,
273
+ target_version=str(selected_version),
274
+ current_dir=self.paths.current_dir,
275
+ )
276
+
277
+ # Update binary launcher
278
+ target_current_exec = self.paths.current_dir / self.config.source_binary_name
279
+ self.os_ops.create_bin_launcher(
280
+ bin_dir=self.paths.bin_dir,
281
+ bin_name=self.config.binary_name,
282
+ target_executable=target_current_exec,
283
+ )
284
+
285
+ return selected_version
286
+
287
+ def uninstall(self, target_version: str | None = None, all_versions: bool = False) -> None:
288
+ """Uninstall a specific version or the entire application."""
289
+ if all_versions:
290
+ # Remove versions dir and current
291
+ if self.target_os == OperatingSystem.WINDOWS:
292
+ app_root = self.paths.lib_dir.parent
293
+ self.os_ops.remove_link_or_dir(app_root)
294
+ else:
295
+ self.os_ops.remove_link_or_dir(self.paths.lib_dir)
296
+
297
+ # Remove launchers
298
+ cmd_launcher = self.paths.bin_dir / f"{self.config.binary_name}.cmd"
299
+ posix_launcher = self.paths.bin_dir / self.config.binary_name
300
+ self.os_ops.remove_link_or_dir(cmd_launcher)
301
+ self.os_ops.remove_link_or_dir(posix_launcher)
302
+
303
+ # Remove from tracking registry
304
+ try:
305
+ self.registry.remove(self.config.binary_name)
306
+ except Exception as e: # pylint: disable=broad-except
307
+ logger.warning("Could not remove app from registry: %s", e)
308
+ return
309
+
310
+ if target_version:
311
+ target_v = parse_semver(target_version)
312
+ if not target_v:
313
+ raise AppManagerError(f"Invalid semver version: '{target_version}'")
314
+
315
+ target_dir = self.paths.versions_dir / str(target_v)
316
+ if not target_dir.exists():
317
+ raise AppManagerError(f"Version '{target_version}' is not installed.")
318
+
319
+ current = self.get_current_version()
320
+
321
+ self.os_ops.remove_link_or_dir(target_dir)
322
+
323
+ # If the current link pointed to the deleted version, fallback to newest remaining
324
+ if current == target_v:
325
+ remaining = self.get_installed_versions()
326
+ if remaining:
327
+ self.rollback(target_version=str(remaining[0]))
328
+ else:
329
+ self.os_ops.remove_link_or_dir(self.paths.current_dir)
330
+ cmd_launcher = self.paths.bin_dir / f"{self.config.binary_name}.cmd"
331
+ posix_launcher = self.paths.bin_dir / self.config.binary_name
332
+ self.os_ops.remove_link_or_dir(cmd_launcher)
333
+ self.os_ops.remove_link_or_dir(posix_launcher)
334
+ return
335
+
336
+ raise AppManagerError("Must specify either --target-version or --all for uninstall.")
337
+
338
+
339
+ __all__ = ["AppManager", "AppManagerError", "VersionRegressionError"]
@@ -0,0 +1,23 @@
1
+ """User-facing CLI output helpers."""
2
+
3
+ import sys
4
+ from typing import TextIO
5
+
6
+
7
+ def write(message: str, stream: TextIO | None = None) -> None:
8
+ """Write a user-facing line to the selected stream."""
9
+ print(message, file=stream or sys.stdout)
10
+
11
+
12
+ def error(message: str) -> None:
13
+ """Write a user-facing error line to stderr."""
14
+ write(message, stream=sys.stderr)
15
+
16
+
17
+ def progress(message: str, quiet: bool = False) -> None:
18
+ """Write progress output unless quiet mode is enabled."""
19
+ if not quiet:
20
+ write(message)
21
+
22
+
23
+ __all__ = ["error", "progress", "write"]