cdx-manager 0.4.3 → 0.4.4
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 +10 -2
- package/changelogs/CHANGELOGS_0_4_4.md +33 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +8 -2
- package/src/cli_commands.py +127 -0
- package/src/session_service.py +1 -0
- package/src/update_check.py +13 -3
- package/src/update_manager.py +208 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -122,7 +122,7 @@ For a specific version:
|
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
124
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
125
|
-
CDX_VERSION=v0.4.
|
|
125
|
+
CDX_VERSION=v0.4.4 sh install.sh
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
From source:
|
|
@@ -143,6 +143,12 @@ npm install -g .
|
|
|
143
143
|
|
|
144
144
|
`cdx` is now available globally. Changes to the source take effect immediately — no reinstall needed.
|
|
145
145
|
|
|
146
|
+
To update an installed copy later:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
cdx update
|
|
150
|
+
```
|
|
151
|
+
|
|
146
152
|
To uninstall:
|
|
147
153
|
|
|
148
154
|
```bash
|
|
@@ -242,6 +248,7 @@ cdx status
|
|
|
242
248
|
| `cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
|
|
243
249
|
| `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
244
250
|
| `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
|
|
251
|
+
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
245
252
|
| `cdx notify <name> --at-reset [--poll seconds] [--once] [--json]` | Wait for a session reset time and send a desktop notification when due |
|
|
246
253
|
| `cdx notify --next-ready [--poll seconds] [--once] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
|
|
247
254
|
| `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
|
|
@@ -272,6 +279,7 @@ Commands with machine-readable output:
|
|
|
272
279
|
- `cdx logout ... --json`
|
|
273
280
|
- `cdx doctor --json`
|
|
274
281
|
- `cdx repair --json`
|
|
282
|
+
- `cdx update --json`
|
|
275
283
|
- `cdx notify ... --json`
|
|
276
284
|
|
|
277
285
|
Success payloads follow a shared envelope:
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog (`0.4.3 -> 0.4.4`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-04-20
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Generated from the release work on `cdx update`, the first built-in self-update path for `cdx-manager`.
|
|
8
|
+
- Added a version-aware update command that can check for a newer release, confirm interactively, and delegate to the right installer for the current installation type.
|
|
9
|
+
- Kept the existing update warning behavior intact so the CLI still surfaces newer releases on startup.
|
|
10
|
+
- Reserved `update` as a session name to avoid collisions with the new command.
|
|
11
|
+
|
|
12
|
+
## `cdx update`
|
|
13
|
+
|
|
14
|
+
- Added `cdx update --check` for a quick release check without applying changes.
|
|
15
|
+
- Added `cdx update --yes` for non-interactive environments.
|
|
16
|
+
- Added `cdx update --version TAG` so maintainers can target a specific release.
|
|
17
|
+
- Routed standalone installs through `install.sh` / `install.ps1`.
|
|
18
|
+
- Routed npm installs through `npm install -g cdx-manager@...`.
|
|
19
|
+
- Routed Python environment installs through `python -m pip install --upgrade ...`.
|
|
20
|
+
- Routed source checkouts through `git pull --ff-only` or an explicit tag checkout when a version is requested.
|
|
21
|
+
- Refused source updates when the checkout contains uncommitted changes.
|
|
22
|
+
|
|
23
|
+
## Validation and Regression Coverage
|
|
24
|
+
|
|
25
|
+
- Added CLI coverage for update checks, update execution, and version-aware help text.
|
|
26
|
+
- Added session-service coverage for the new reserved command name.
|
|
27
|
+
- Added unit coverage for installation detection and source-checkout safety in the update planner.
|
|
28
|
+
- Kept the existing CLI and session-service test suites green.
|
|
29
|
+
|
|
30
|
+
## Validation and Regression Evidence
|
|
31
|
+
|
|
32
|
+
- `python3 -m unittest test.test_cli_py test.test_session_service_py test.test_update_manager_py`
|
|
33
|
+
- `npm run lint`
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -21,6 +21,7 @@ from .cli_commands import (
|
|
|
21
21
|
handle_repair,
|
|
22
22
|
handle_rename,
|
|
23
23
|
handle_status,
|
|
24
|
+
handle_update,
|
|
24
25
|
)
|
|
25
26
|
from .cli_render import (
|
|
26
27
|
_format_sessions,
|
|
@@ -44,7 +45,7 @@ from .status_view import (
|
|
|
44
45
|
)
|
|
45
46
|
from .update_check import check_for_update
|
|
46
47
|
|
|
47
|
-
VERSION = "0.4.
|
|
48
|
+
VERSION = "0.4.4"
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
# ---------------------------------------------------------------------------
|
|
@@ -72,6 +73,7 @@ def _print_help(use_color=False):
|
|
|
72
73
|
f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
|
|
73
74
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
74
75
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
76
|
+
f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
75
77
|
f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
|
|
76
78
|
f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
|
|
77
79
|
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
@@ -205,8 +207,9 @@ def main(argv, options=None):
|
|
|
205
207
|
"spawn": spawn,
|
|
206
208
|
"spawn_sync": spawn_sync,
|
|
207
209
|
"stdin_is_tty": stdin_is_tty,
|
|
210
|
+
"version": VERSION,
|
|
208
211
|
"update_notice": _get_update_notice(service, env, options) if command not in (
|
|
209
|
-
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "notify", "status", "login", "logout", "export", "import", "help", "version"
|
|
212
|
+
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "login", "logout", "export", "import", "help", "version"
|
|
210
213
|
) else None,
|
|
211
214
|
"use_color": use_color,
|
|
212
215
|
}
|
|
@@ -238,6 +241,9 @@ def main(argv, options=None):
|
|
|
238
241
|
if command == "repair":
|
|
239
242
|
return handle_repair(rest, ctx)
|
|
240
243
|
|
|
244
|
+
if command == "update":
|
|
245
|
+
return handle_update(rest, ctx)
|
|
246
|
+
|
|
241
247
|
if command == "notify":
|
|
242
248
|
return handle_notify(rest, ctx)
|
|
243
249
|
|
package/src/cli_commands.py
CHANGED
|
@@ -22,11 +22,14 @@ from .provider_runtime import (
|
|
|
22
22
|
from .repair import format_repair_report, repair_health
|
|
23
23
|
from .backup_bundle import read_bundle_meta
|
|
24
24
|
from .status_view import _format_status_detail, _format_status_rows
|
|
25
|
+
from .update_check import fetch_latest_release, is_newer_version
|
|
26
|
+
from .update_manager import build_update_plan, format_update_failure, run_update_plan
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
|
|
28
30
|
DOCTOR_USAGE = "Usage: cdx doctor [--json]"
|
|
29
31
|
REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
32
|
+
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
30
33
|
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
31
34
|
IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
32
35
|
API_SCHEMA_VERSION = 1
|
|
@@ -102,6 +105,45 @@ def _parse_session_names(value):
|
|
|
102
105
|
return names
|
|
103
106
|
|
|
104
107
|
|
|
108
|
+
def _parse_update_args(args):
|
|
109
|
+
parsed = {
|
|
110
|
+
"check": False,
|
|
111
|
+
"json": False,
|
|
112
|
+
"yes": False,
|
|
113
|
+
"version": None,
|
|
114
|
+
}
|
|
115
|
+
index = 0
|
|
116
|
+
while index < len(args):
|
|
117
|
+
arg = args[index]
|
|
118
|
+
if arg == "--check":
|
|
119
|
+
parsed["check"] = True
|
|
120
|
+
index += 1
|
|
121
|
+
continue
|
|
122
|
+
if arg == "--json":
|
|
123
|
+
parsed["json"] = True
|
|
124
|
+
index += 1
|
|
125
|
+
continue
|
|
126
|
+
if arg == "--yes":
|
|
127
|
+
parsed["yes"] = True
|
|
128
|
+
index += 1
|
|
129
|
+
continue
|
|
130
|
+
if arg == "--version":
|
|
131
|
+
value, index = _read_option_value(args, index, UPDATE_USAGE)
|
|
132
|
+
parsed["version"] = value
|
|
133
|
+
continue
|
|
134
|
+
if arg.startswith("--version="):
|
|
135
|
+
parsed["version"] = arg.split("=", 1)[1]
|
|
136
|
+
index += 1
|
|
137
|
+
continue
|
|
138
|
+
raise CdxError(UPDATE_USAGE)
|
|
139
|
+
|
|
140
|
+
if parsed["check"] and parsed["version"]:
|
|
141
|
+
raise CdxError("Usage: cdx update --check cannot be combined with --version.")
|
|
142
|
+
if parsed["version"] is not None and not parsed["version"].strip():
|
|
143
|
+
raise CdxError("Usage: cdx update [--check] [--yes] [--json] [--version TAG]")
|
|
144
|
+
return parsed
|
|
145
|
+
|
|
146
|
+
|
|
105
147
|
def _parse_export_args(args):
|
|
106
148
|
parsed = {
|
|
107
149
|
"file_path": None,
|
|
@@ -649,6 +691,91 @@ def handle_logout(rest, ctx):
|
|
|
649
691
|
return 0
|
|
650
692
|
|
|
651
693
|
|
|
694
|
+
def handle_update(rest, ctx):
|
|
695
|
+
parsed = _parse_update_args(rest)
|
|
696
|
+
json_flag = parsed["json"]
|
|
697
|
+
current_version = str(ctx.get("version") or "").strip()
|
|
698
|
+
release_fetcher = ctx["options"].get("fetchLatestRelease") or fetch_latest_release
|
|
699
|
+
target_version = None
|
|
700
|
+
release_url = None
|
|
701
|
+
update_available = False
|
|
702
|
+
|
|
703
|
+
if parsed["version"] is not None:
|
|
704
|
+
target_version = str(parsed["version"]).strip().lstrip("v")
|
|
705
|
+
else:
|
|
706
|
+
latest = release_fetcher()
|
|
707
|
+
if not latest:
|
|
708
|
+
raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
|
|
709
|
+
target_version = str(latest.get("latest_version") or "").strip()
|
|
710
|
+
release_url = latest.get("url")
|
|
711
|
+
if not target_version:
|
|
712
|
+
raise CdxError("Unable to determine the latest cdx-manager release.")
|
|
713
|
+
update_available = is_newer_version(current_version, target_version)
|
|
714
|
+
if parsed["check"] or not update_available:
|
|
715
|
+
message = (
|
|
716
|
+
f"Update available: cdx-manager {target_version} (current {current_version})"
|
|
717
|
+
if update_available
|
|
718
|
+
else f"cdx-manager {current_version} is already up to date."
|
|
719
|
+
)
|
|
720
|
+
if json_flag:
|
|
721
|
+
_write_json(ctx, _json_success(
|
|
722
|
+
"update",
|
|
723
|
+
message,
|
|
724
|
+
checked=True,
|
|
725
|
+
update_available=update_available,
|
|
726
|
+
current_version=current_version,
|
|
727
|
+
target_version=target_version,
|
|
728
|
+
release_url=release_url,
|
|
729
|
+
warnings=[{
|
|
730
|
+
"code": "update_available",
|
|
731
|
+
"message": message,
|
|
732
|
+
"latest_version": target_version,
|
|
733
|
+
"url": release_url,
|
|
734
|
+
}] if update_available else [],
|
|
735
|
+
))
|
|
736
|
+
return 0
|
|
737
|
+
ctx["out"](f"{_warn(message, ctx['use_color']) if update_available else _success(message, ctx['use_color'])}\n")
|
|
738
|
+
return 0
|
|
739
|
+
|
|
740
|
+
if not parsed["yes"]:
|
|
741
|
+
if not ctx["stdin_is_tty"]:
|
|
742
|
+
raise CdxError("Update requires an interactive terminal or --yes in non-interactive mode.")
|
|
743
|
+
answer = input(f"Update cdx-manager to {target_version}? [y/N] ")
|
|
744
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
745
|
+
message = "Cancelled."
|
|
746
|
+
if json_flag:
|
|
747
|
+
_write_json(ctx, _json_success("update", message, cancelled=True, current_version=current_version, target_version=target_version))
|
|
748
|
+
return 0
|
|
749
|
+
ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
|
|
750
|
+
return 0
|
|
751
|
+
|
|
752
|
+
plan = build_update_plan(
|
|
753
|
+
target_version=target_version,
|
|
754
|
+
package_root=ctx["options"].get("packageRoot"),
|
|
755
|
+
prefix=ctx["options"].get("prefix"),
|
|
756
|
+
base_prefix=ctx["options"].get("basePrefix"),
|
|
757
|
+
)
|
|
758
|
+
results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
|
|
759
|
+
failed = any((result.get("returncode") not in (0, None)) for result in results)
|
|
760
|
+
if failed:
|
|
761
|
+
raise CdxError(format_update_failure(results))
|
|
762
|
+
|
|
763
|
+
message = f"Updated cdx-manager to {target_version}"
|
|
764
|
+
if json_flag:
|
|
765
|
+
_write_json(ctx, _json_success(
|
|
766
|
+
"update",
|
|
767
|
+
message,
|
|
768
|
+
updated=True,
|
|
769
|
+
current_version=current_version,
|
|
770
|
+
target_version=target_version,
|
|
771
|
+
mode=plan["mode"],
|
|
772
|
+
steps=results,
|
|
773
|
+
))
|
|
774
|
+
return 0
|
|
775
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
776
|
+
return 0
|
|
777
|
+
|
|
778
|
+
|
|
652
779
|
def handle_launch(command, ctx):
|
|
653
780
|
json_flag = "--json" in ctx["options"].get("raw_args", [])
|
|
654
781
|
update_notice = ctx.get("update_notice")
|
package/src/session_service.py
CHANGED
package/src/update_check.py
CHANGED
|
@@ -28,6 +28,10 @@ def _is_newer_version(current_version, latest_version):
|
|
|
28
28
|
return latest > current
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def is_newer_version(current_version, latest_version):
|
|
32
|
+
return _is_newer_version(current_version, latest_version)
|
|
33
|
+
|
|
34
|
+
|
|
31
35
|
def _cache_path(base_dir):
|
|
32
36
|
return os.path.join(base_dir, "state", "update-check.json")
|
|
33
37
|
|
|
@@ -63,6 +67,13 @@ def _fetch_latest_release():
|
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
|
|
70
|
+
def fetch_latest_release():
|
|
71
|
+
try:
|
|
72
|
+
return _fetch_latest_release()
|
|
73
|
+
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
66
77
|
def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
67
78
|
env = env or os.environ
|
|
68
79
|
now_fn = now_fn or (lambda: datetime.now(timezone.utc).timestamp())
|
|
@@ -83,9 +94,8 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
|
83
94
|
}
|
|
84
95
|
return None
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
|
|
97
|
+
latest = fetch_latest_release()
|
|
98
|
+
if not latest:
|
|
89
99
|
return None
|
|
90
100
|
|
|
91
101
|
payload = {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import CdxError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _package_root(path=None):
|
|
10
|
+
if path is not None:
|
|
11
|
+
return Path(path).resolve()
|
|
12
|
+
return Path(__file__).resolve().parents[1]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_version(value):
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
text = str(value).strip()
|
|
19
|
+
if not text:
|
|
20
|
+
return None
|
|
21
|
+
return text.lstrip("v")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_standalone_install(package_root):
|
|
25
|
+
return package_root.parent.name == "versions"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_source_checkout(package_root):
|
|
29
|
+
return (package_root / ".git").exists()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_git_dirty(package_root):
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["git", "-C", str(package_root), "status", "--porcelain"],
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
except FileNotFoundError as error:
|
|
41
|
+
raise CdxError("git is required to update a source checkout.") from error
|
|
42
|
+
return bool((result.stdout or "").strip())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_python_env(prefix=None, base_prefix=None):
|
|
46
|
+
prefix = prefix or sys.prefix
|
|
47
|
+
base_prefix = base_prefix or sys.base_prefix
|
|
48
|
+
return prefix != base_prefix
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_installation(package_root=None, prefix=None, base_prefix=None):
|
|
52
|
+
root = _package_root(package_root)
|
|
53
|
+
if _is_standalone_install(root):
|
|
54
|
+
return {"mode": "standalone", "package_root": str(root)}
|
|
55
|
+
if _is_source_checkout(root):
|
|
56
|
+
return {"mode": "source", "package_root": str(root)}
|
|
57
|
+
if _is_python_env(prefix=prefix, base_prefix=base_prefix):
|
|
58
|
+
return {"mode": "python", "package_root": str(root)}
|
|
59
|
+
if (root / "package.json").exists():
|
|
60
|
+
return {"mode": "npm", "package_root": str(root)}
|
|
61
|
+
return {"mode": "unknown", "package_root": str(root)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _join_command(*parts):
|
|
65
|
+
return [str(part) for part in parts if part is not None]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_standalone_step(package_root, target_version):
|
|
69
|
+
package_root = _package_root(package_root)
|
|
70
|
+
env = {}
|
|
71
|
+
if target_version:
|
|
72
|
+
env["CDX_VERSION"] = target_version
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
return {
|
|
75
|
+
"label": "standalone installer",
|
|
76
|
+
"command": _join_command("powershell", "-ExecutionPolicy", "Bypass", "-File", package_root / "install.ps1"),
|
|
77
|
+
"cwd": str(package_root),
|
|
78
|
+
"env": env,
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
"label": "standalone installer",
|
|
82
|
+
"command": _join_command("sh", package_root / "install.sh"),
|
|
83
|
+
"cwd": str(package_root),
|
|
84
|
+
"env": env,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_source_steps(package_root, target_version):
|
|
89
|
+
package_root = _package_root(package_root)
|
|
90
|
+
if _is_git_dirty(package_root):
|
|
91
|
+
raise CdxError(
|
|
92
|
+
"Your source checkout has uncommitted changes. "
|
|
93
|
+
"Commit or stash them before running cdx update."
|
|
94
|
+
)
|
|
95
|
+
if target_version:
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
"label": "fetch tags",
|
|
99
|
+
"command": _join_command("git", "-C", package_root, "fetch", "--tags", "--force"),
|
|
100
|
+
"cwd": str(package_root),
|
|
101
|
+
"env": {},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"label": f"checkout v{target_version}",
|
|
105
|
+
"command": _join_command("git", "-C", package_root, "checkout", f"v{target_version}"),
|
|
106
|
+
"cwd": str(package_root),
|
|
107
|
+
"env": {},
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
"label": "git pull --ff-only",
|
|
113
|
+
"command": _join_command("git", "-C", package_root, "pull", "--ff-only"),
|
|
114
|
+
"cwd": str(package_root),
|
|
115
|
+
"env": {},
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_python_step(target_version):
|
|
121
|
+
command = [sys.executable, "-m", "pip", "install", "--upgrade"]
|
|
122
|
+
if target_version:
|
|
123
|
+
command.append(f"cdx-manager=={target_version}")
|
|
124
|
+
else:
|
|
125
|
+
command.append("cdx-manager")
|
|
126
|
+
return {
|
|
127
|
+
"label": "python package upgrade",
|
|
128
|
+
"command": command,
|
|
129
|
+
"cwd": None,
|
|
130
|
+
"env": {},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _build_npm_step(target_version):
|
|
135
|
+
spec = f"cdx-manager@{target_version}" if target_version else "cdx-manager@latest"
|
|
136
|
+
return {
|
|
137
|
+
"label": "npm global upgrade",
|
|
138
|
+
"command": ["npm", "install", "-g", spec],
|
|
139
|
+
"cwd": None,
|
|
140
|
+
"env": {},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_update_plan(target_version=None, package_root=None, env=None, prefix=None, base_prefix=None):
|
|
145
|
+
root = _package_root(package_root)
|
|
146
|
+
version = _normalize_version(target_version)
|
|
147
|
+
detection = detect_installation(root, prefix=prefix, base_prefix=base_prefix)
|
|
148
|
+
mode = detection["mode"]
|
|
149
|
+
if mode == "standalone":
|
|
150
|
+
steps = [_build_standalone_step(root, version)]
|
|
151
|
+
elif mode == "source":
|
|
152
|
+
steps = _build_source_steps(root, version)
|
|
153
|
+
elif mode == "python":
|
|
154
|
+
steps = [_build_python_step(version)]
|
|
155
|
+
elif mode == "npm":
|
|
156
|
+
steps = [_build_npm_step(version)]
|
|
157
|
+
else:
|
|
158
|
+
raise CdxError(
|
|
159
|
+
"Unable to determine how cdx-manager was installed. "
|
|
160
|
+
"Set CDX_UPDATE_METHOD or update it manually."
|
|
161
|
+
)
|
|
162
|
+
return {
|
|
163
|
+
"mode": mode,
|
|
164
|
+
"package_root": str(root),
|
|
165
|
+
"target_version": version,
|
|
166
|
+
"steps": steps,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _result_code(result):
|
|
171
|
+
if isinstance(result, dict):
|
|
172
|
+
return result.get("returncode") if result.get("returncode") is not None else result.get("status")
|
|
173
|
+
return getattr(result, "returncode", getattr(result, "status", None))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _result_text(result, attr):
|
|
177
|
+
if isinstance(result, dict):
|
|
178
|
+
return result.get(attr)
|
|
179
|
+
return getattr(result, attr, "")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_update_plan(plan, runner=None, env=None):
|
|
183
|
+
runner = runner or subprocess.run
|
|
184
|
+
results = []
|
|
185
|
+
for step in plan["steps"]:
|
|
186
|
+
step_env = {**(env or os.environ), **(step.get("env") or {})}
|
|
187
|
+
kwargs = {"cwd": step.get("cwd"), "env": step_env, "check": False}
|
|
188
|
+
result = runner(step["command"], **kwargs)
|
|
189
|
+
code = _result_code(result)
|
|
190
|
+
results.append({
|
|
191
|
+
"label": step["label"],
|
|
192
|
+
"command": step["command"],
|
|
193
|
+
"cwd": step.get("cwd"),
|
|
194
|
+
"returncode": code,
|
|
195
|
+
"stdout": _result_text(result, "stdout"),
|
|
196
|
+
"stderr": _result_text(result, "stderr"),
|
|
197
|
+
})
|
|
198
|
+
if code not in (0, None):
|
|
199
|
+
break
|
|
200
|
+
return results
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def format_update_failure(results):
|
|
204
|
+
if not results:
|
|
205
|
+
return "Update failed."
|
|
206
|
+
last = results[-1]
|
|
207
|
+
message = last.get("stderr") or last.get("stdout") or "Update failed."
|
|
208
|
+
return f"{last['label']} failed: {str(message).strip()}"
|