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.
- net_install_manager-0.1.0/LICENSE +21 -0
- net_install_manager-0.1.0/PKG-INFO +105 -0
- net_install_manager-0.1.0/README.md +80 -0
- net_install_manager-0.1.0/pyproject.toml +63 -0
- net_install_manager-0.1.0/src/net_install_manager/__init__.py +17 -0
- net_install_manager-0.1.0/src/net_install_manager/app_manager.py +339 -0
- net_install_manager-0.1.0/src/net_install_manager/cli_output.py +23 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/__init__.py +0 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/app_details.py +59 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/install.py +114 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/install_release.py +142 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/list_apps.py +37 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/list_versions.py +68 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/rollback.py +59 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/uninstall.py +71 -0
- net_install_manager-0.1.0/src/net_install_manager/commands/upgrade.py +272 -0
- net_install_manager-0.1.0/src/net_install_manager/config.py +124 -0
- net_install_manager-0.1.0/src/net_install_manager/errors.py +35 -0
- net_install_manager-0.1.0/src/net_install_manager/net_install_manager.py +268 -0
- net_install_manager-0.1.0/src/net_install_manager/paths.py +73 -0
- net_install_manager-0.1.0/src/net_install_manager/registry.py +176 -0
- net_install_manager-0.1.0/src/net_install_manager/runtime_config.py +59 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/__init__.py +0 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/csproj_tools.py +112 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/git_tools.py +219 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/github_release_tools.py +202 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/install_tools.py +189 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/os_ops.py +325 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/resolve_manager.py +33 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/sh.py +84 -0
- net_install_manager-0.1.0/src/net_install_manager/utilities/sys_platform.py +133 -0
- 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"]
|
|
File without changes
|