devin-devkit-workspace 0.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.
- devin_devkit_workspace-0.0.0.dist-info/METADATA +7 -0
- devin_devkit_workspace-0.0.0.dist-info/RECORD +13 -0
- devin_devkit_workspace-0.0.0.dist-info/WHEEL +5 -0
- devin_devkit_workspace-0.0.0.dist-info/licenses/LICENSE +21 -0
- devin_devkit_workspace-0.0.0.dist-info/top_level.txt +1 -0
- packages/devkit/src/devin_devkit/__init__.py +1 -0
- packages/devkit/src/devin_devkit/cli.py +128 -0
- packages/devkit/src/devin_devkit/installer.py +213 -0
- packages/devkit/src/devin_devkit/updater.py +262 -0
- packages/devkit/tests/test_cli.py +79 -0
- packages/devkit/tests/test_installer.py +226 -0
- packages/devkit/tests/test_smoke.py +4 -0
- packages/devkit/tests/test_updater.py +233 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
devin_devkit_workspace-0.0.0.dist-info/licenses/LICENSE,sha256=dxS8nFGmsVpbMH3Qz4fmf0j6irSSpO4k5H5F6ZealWg,1066
|
|
2
|
+
packages/devkit/src/devin_devkit/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
packages/devkit/src/devin_devkit/cli.py,sha256=d47vn96QAuW-inihysQ6PZnC_YoWjP1WVKGnMaBMuJE,6348
|
|
4
|
+
packages/devkit/src/devin_devkit/installer.py,sha256=4sD6jF4mhiXCRiHKHfR8MnS9igPMSow5ltRcpMGtvuc,9369
|
|
5
|
+
packages/devkit/src/devin_devkit/updater.py,sha256=Ula6hTPJul1LPd0pqcOzmd4iAKem3LMXHrrK_tC23TU,8916
|
|
6
|
+
packages/devkit/tests/test_cli.py,sha256=uu0A-BQ0FT-UN8rD3kVdDvF8u-JAUp6k16siHXxlCxk,2886
|
|
7
|
+
packages/devkit/tests/test_installer.py,sha256=Rth95irzttYwlT_KDoEC_ZEni9CIOmy9WTsyFG5Lj14,11081
|
|
8
|
+
packages/devkit/tests/test_smoke.py,sha256=6jAwEWLs6iLIvq-PfrfR-ILSmRob29MxrAA-l6pPcAg,80
|
|
9
|
+
packages/devkit/tests/test_updater.py,sha256=HXJRqdy_woNs6v0QrZnTi3CV96BVoW-Sj-VJ9yhmqNg,9030
|
|
10
|
+
devin_devkit_workspace-0.0.0.dist-info/METADATA,sha256=tt31MduRpnuj2koIXLbh0Dl2QfYusfJGK8mXMfQrLug,215
|
|
11
|
+
devin_devkit_workspace-0.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
devin_devkit_workspace-0.0.0.dist-info/top_level.txt,sha256=KlHxfoENEXdJ6vF3m8uvAis67kLBNoPQkFKHOFVIVlM,9
|
|
13
|
+
devin_devkit_workspace-0.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Icaro0310
|
|
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 @@
|
|
|
1
|
+
packages
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from devin_devkit.installer import DevKitError, apply_plan, build_plan, load_manifest
|
|
10
|
+
from devin_devkit.updater import (
|
|
11
|
+
apply_update_plan,
|
|
12
|
+
build_update_plan,
|
|
13
|
+
fetch_remote_manifest,
|
|
14
|
+
freshness_hint,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _print_plan(plan: dict[str, Any], as_json: bool) -> None:
|
|
19
|
+
if as_json:
|
|
20
|
+
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
|
21
|
+
return
|
|
22
|
+
print(f"Profile: {plan['profile']} ({plan['platform']}; environment={plan['environment']}; runtime={plan['runtime']})")
|
|
23
|
+
for item in plan["actions"]:
|
|
24
|
+
command = " ".join(item.get("command", []))
|
|
25
|
+
detail = item.get("detail", "")
|
|
26
|
+
suffix = f" — {detail}" if detail else ""
|
|
27
|
+
print(f"- {item['tool']}: {item['action']}{suffix}{': ' + command if command else ''}")
|
|
28
|
+
for error in plan["errors"]:
|
|
29
|
+
print(f"BLOCKED: {error}", file=sys.stderr)
|
|
30
|
+
if not plan["errors"] and any(item["action"] == "install" for item in plan["actions"]):
|
|
31
|
+
print("Dry run only. Re-run with --apply to install.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(prog="devin-devkit", description="Install curated Devin tool profiles with isolated uv/npm environments.")
|
|
36
|
+
parser.add_argument("--manifest", type=Path, help=argparse.SUPPRESS)
|
|
37
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
38
|
+
sub.add_parser("profiles", help="list available installation profiles")
|
|
39
|
+
sub.add_parser("list", help="list the tools in the manifest")
|
|
40
|
+
install = sub.add_parser("install", help="preview a profile; pass --apply to install")
|
|
41
|
+
install.add_argument("profile")
|
|
42
|
+
install.add_argument("--apply", action="store_true", help="install the selected profile")
|
|
43
|
+
install.add_argument("--json", action="store_true", help="print the plan as JSON")
|
|
44
|
+
install.add_argument(
|
|
45
|
+
"--environment",
|
|
46
|
+
choices=["linux", "personal-windows", "corporate-windows"],
|
|
47
|
+
help="execution environment; defaults to linux on Linux and personal-windows on Windows",
|
|
48
|
+
)
|
|
49
|
+
outdated = sub.add_parser("outdated", help="list installed tools with a newer version in the remote registry")
|
|
50
|
+
outdated.add_argument("--json", action="store_true", help="print the check as JSON")
|
|
51
|
+
update = sub.add_parser("update", help="preview tool updates; pass --apply to install them")
|
|
52
|
+
update.add_argument("--apply", action="store_true", help="reinstall outdated tools")
|
|
53
|
+
update.add_argument("--force", action="store_true", help="reinstall every installed tool")
|
|
54
|
+
update.add_argument("--json", action="store_true", help="print the plan as JSON")
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: list[str] | None = None) -> int:
|
|
59
|
+
parser = _build_parser()
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
try:
|
|
62
|
+
manifest = load_manifest(args.manifest)
|
|
63
|
+
if args.action in {"outdated", "update"}:
|
|
64
|
+
remote = fetch_remote_manifest()
|
|
65
|
+
plan = build_update_plan(remote, force=args.action == "update" and args.force)
|
|
66
|
+
if args.json:
|
|
67
|
+
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
|
68
|
+
else:
|
|
69
|
+
print(f"Remote registry v{plan['registry_version']} (generated {plan['generated']})")
|
|
70
|
+
for item in plan["actions"]:
|
|
71
|
+
if item["action"] == "update":
|
|
72
|
+
print(f"- {item['tool']}: {item['from']} -> {item['to']}")
|
|
73
|
+
elif item["action"] == "current":
|
|
74
|
+
print(f"- {item['tool']}: up to date ({item['version']})")
|
|
75
|
+
elif item["action"] == "blocked":
|
|
76
|
+
print(f"- {item['tool']}: BLOCKED — {item['detail']}")
|
|
77
|
+
if args.action == "outdated":
|
|
78
|
+
return 0
|
|
79
|
+
pending = [a for a in plan["actions"] if a["action"] == "update"]
|
|
80
|
+
if not args.apply:
|
|
81
|
+
if pending:
|
|
82
|
+
print("Dry run only. Re-run with --apply to update.")
|
|
83
|
+
return 0
|
|
84
|
+
result = apply_update_plan(plan)
|
|
85
|
+
print(f"Updated: {', '.join(result['updated']) or 'nothing'}")
|
|
86
|
+
for name in result["failed"]:
|
|
87
|
+
print(f"FAILED: {name}", file=sys.stderr)
|
|
88
|
+
return 1 if result["failed"] else 0
|
|
89
|
+
if args.action == "profiles":
|
|
90
|
+
for name, profile in manifest["profiles"].items():
|
|
91
|
+
alias = f" (alias of {profile['alias_of']})" if profile.get("alias_of") else ""
|
|
92
|
+
print(f"{name}: {profile['label']}{alias} — {profile['description']}")
|
|
93
|
+
return 0
|
|
94
|
+
if args.action == "list":
|
|
95
|
+
cat = manifest["catalog"]
|
|
96
|
+
pl = lambda n: "" if n == 1 else "s" # noqa: E731
|
|
97
|
+
print(f"{cat['tool_count']} first-party Devin tools; {cat['distribution_count']} distribution layer{pl(cat['distribution_count'])}; {cat['hub_count']} maintainer hub{pl(cat['hub_count'])}; {cat['related_count']} related project{pl(cat['related_count'])}")
|
|
98
|
+
for tool in manifest["tools"]:
|
|
99
|
+
print(f"{tool['id']}: {tool['package']} {tool['version']} [{tool['manager']}/{tool['source']}/{tool['status']}]")
|
|
100
|
+
hint = freshness_hint(manifest)
|
|
101
|
+
if hint:
|
|
102
|
+
print(hint, file=sys.stderr)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
plan = build_plan(manifest, args.profile, environment=args.environment)
|
|
106
|
+
if not args.apply:
|
|
107
|
+
_print_plan(plan, args.json)
|
|
108
|
+
hint = freshness_hint(manifest)
|
|
109
|
+
if hint:
|
|
110
|
+
print(hint, file=sys.stderr)
|
|
111
|
+
return 2 if plan["errors"] else 0
|
|
112
|
+
if plan["errors"]:
|
|
113
|
+
_print_plan(plan, args.json)
|
|
114
|
+
return 2
|
|
115
|
+
result = apply_plan(plan)
|
|
116
|
+
print(json.dumps(result, ensure_ascii=False, indent=2) if args.json else f"Installed: {', '.join(result['installed']) or 'nothing new'}")
|
|
117
|
+
for item in result["actions"]:
|
|
118
|
+
if item["action"] in {"preexisting", "manual", "unsupported"}:
|
|
119
|
+
detail = item.get("detail", ", ".join(item.get("commands", [])))
|
|
120
|
+
print(f"- {item['tool']}: {item['action']} — {detail}")
|
|
121
|
+
return 0
|
|
122
|
+
except DevKitError as exc:
|
|
123
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
124
|
+
return 2
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import platform as platform_module
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DevKitError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_manifest(path: Path | None = None) -> dict[str, Any]:
|
|
18
|
+
source = path if path is not None else files("devin_devkit").joinpath("manifest.json")
|
|
19
|
+
try:
|
|
20
|
+
text = Path(source).read_text(encoding="utf-8") if path else source.read_text(encoding="utf-8")
|
|
21
|
+
return json.loads(text)
|
|
22
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
23
|
+
raise DevKitError(f"cannot load manifest {source}: {exc}") from exc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def platform_name(system: str | None = None) -> str:
|
|
27
|
+
value = (system or platform_module.system()).lower()
|
|
28
|
+
if value in {"nt", "windows"}:
|
|
29
|
+
return "windows"
|
|
30
|
+
if value in {"posix", "linux"}:
|
|
31
|
+
return "linux"
|
|
32
|
+
if value in {"darwin", "macos"}:
|
|
33
|
+
return "macos"
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_ENVIRONMENT_ALIASES = {
|
|
38
|
+
"linux": "linux",
|
|
39
|
+
"personal-windows": "personal_windows",
|
|
40
|
+
"personal_windows": "personal_windows",
|
|
41
|
+
"windows": "personal_windows",
|
|
42
|
+
"corporate-windows": "corporate_windows",
|
|
43
|
+
"corporate_windows": "corporate_windows",
|
|
44
|
+
"corporate": "corporate_windows",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_environment(environment: str | None, host: str) -> str:
|
|
49
|
+
if environment is None:
|
|
50
|
+
return "personal_windows" if host == "windows" else host
|
|
51
|
+
normalized = _ENVIRONMENT_ALIASES.get(environment.lower())
|
|
52
|
+
if normalized is None:
|
|
53
|
+
raise ValueError(f"unsupported environment: {environment}")
|
|
54
|
+
expected_host = "windows" if normalized.endswith("_windows") else normalized
|
|
55
|
+
if host != expected_host:
|
|
56
|
+
raise ValueError(f"environment {normalized} cannot run on host platform {host}")
|
|
57
|
+
return normalized
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_profile(manifest: dict[str, Any], name: str) -> tuple[str, dict[str, Any]]:
|
|
61
|
+
profiles = manifest.get("profiles", {})
|
|
62
|
+
if name not in profiles:
|
|
63
|
+
raise DevKitError(f"unknown profile {name!r}; choose from: {', '.join(sorted(profiles))}")
|
|
64
|
+
current = name
|
|
65
|
+
seen: set[str] = set()
|
|
66
|
+
while profiles[current].get("alias_of"):
|
|
67
|
+
if current in seen:
|
|
68
|
+
raise DevKitError(f"profile alias cycle at {current!r}")
|
|
69
|
+
seen.add(current)
|
|
70
|
+
current = profiles[current]["alias_of"]
|
|
71
|
+
if current not in profiles:
|
|
72
|
+
raise DevKitError(f"profile {name!r} points to missing alias target {current!r}")
|
|
73
|
+
return current, profiles[current]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _node_major(runner: Callable[..., Any], which: Callable[[str], str | None]) -> int | None:
|
|
77
|
+
if which("node") is None:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
result = runner(["node", "--version"], capture_output=True, text=True, check=False)
|
|
81
|
+
except OSError:
|
|
82
|
+
return None
|
|
83
|
+
match = re.search(r"v?(\d+)", result.stdout or "")
|
|
84
|
+
return int(match.group(1)) if match else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_plan(
|
|
88
|
+
manifest: dict[str, Any],
|
|
89
|
+
profile_name: str,
|
|
90
|
+
*,
|
|
91
|
+
system: str | None = None,
|
|
92
|
+
environment: str | None = None,
|
|
93
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
94
|
+
runner: Callable[..., Any] = subprocess.run,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
resolved_name, profile = resolve_profile(manifest, profile_name)
|
|
97
|
+
host = platform_name(system)
|
|
98
|
+
if host not in manifest.get("supported_platforms", []):
|
|
99
|
+
planned = ", ".join(manifest.get("planned_platforms", [])) or "none"
|
|
100
|
+
raise DevKitError(f"platform {host!r} is not supported; planned platforms: {planned}")
|
|
101
|
+
try:
|
|
102
|
+
selected_environment = normalize_environment(environment, host)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
raise DevKitError(str(exc)) from exc
|
|
105
|
+
if selected_environment not in manifest.get("environments", {}):
|
|
106
|
+
raise DevKitError(f"manifest does not define environment {selected_environment!r}")
|
|
107
|
+
|
|
108
|
+
tool_map = {tool["id"]: tool for tool in manifest.get("tools", [])}
|
|
109
|
+
actions: list[dict[str, Any]] = []
|
|
110
|
+
errors: list[str] = []
|
|
111
|
+
node_major = None
|
|
112
|
+
|
|
113
|
+
for tool_id in profile.get("tools", []):
|
|
114
|
+
tool = tool_map.get(tool_id)
|
|
115
|
+
if tool is None:
|
|
116
|
+
errors.append(f"profile {resolved_name!r} references unknown tool {tool_id!r}")
|
|
117
|
+
continue
|
|
118
|
+
environment_meta = tool.get("environments", {}).get(selected_environment)
|
|
119
|
+
if environment_meta is None:
|
|
120
|
+
errors.append(f"{tool_id} has no {selected_environment} environment metadata")
|
|
121
|
+
actions.append({"tool": tool_id, "action": "unsupported", "detail": f"no metadata for {selected_environment}"})
|
|
122
|
+
continue
|
|
123
|
+
if not environment_meta.get("supported"):
|
|
124
|
+
reason = environment_meta.get("reason", f"not listed for {selected_environment}")
|
|
125
|
+
errors.append(f"{tool_id} is unsupported in {selected_environment}: {reason}")
|
|
126
|
+
actions.append({"tool": tool_id, "action": "unsupported", "detail": reason})
|
|
127
|
+
continue
|
|
128
|
+
if selected_environment == "corporate_windows" and (
|
|
129
|
+
environment_meta.get("external_dependencies") or environment_meta.get("delegation") in {"optional", "supported", "required"}
|
|
130
|
+
):
|
|
131
|
+
reason = "external runtime or delegation is disabled in corporate Windows"
|
|
132
|
+
errors.append(f"{tool_id} violates the corporate Windows local-only guarantee")
|
|
133
|
+
actions.append({"tool": tool_id, "action": "blocked", "detail": reason})
|
|
134
|
+
continue
|
|
135
|
+
if host not in tool.get("platforms", []):
|
|
136
|
+
actions.append({"tool": tool_id, "action": "unsupported", "detail": f"not listed for {host}"})
|
|
137
|
+
continue
|
|
138
|
+
if tool.get("status") == "manual" or tool.get("manager") == "manual":
|
|
139
|
+
actions.append({"tool": tool_id, "action": "manual", "detail": tool.get("manual_note", "manual setup required")})
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
manager = tool["manager"]
|
|
143
|
+
commands = tool.get("commands", [])
|
|
144
|
+
present = [command for command in commands if which(command) is not None]
|
|
145
|
+
if present:
|
|
146
|
+
if len(present) == len(commands):
|
|
147
|
+
actions.append({"tool": tool_id, "action": "preexisting", "detail": "commands are on PATH; versions are not verified", "commands": present})
|
|
148
|
+
else:
|
|
149
|
+
missing = [command for command in commands if command not in present]
|
|
150
|
+
errors.append(f"{tool_id} has a partial PATH collision; present={present}, missing={missing}")
|
|
151
|
+
actions.append({"tool": tool_id, "action": "blocked", "detail": "partial command collision"})
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
manager_binary = "uv" if manager == "uv" else "npm"
|
|
155
|
+
if which(manager_binary) is None:
|
|
156
|
+
errors.append(f"{tool_id} requires {manager_binary} on PATH")
|
|
157
|
+
actions.append({"tool": tool_id, "action": "blocked", "detail": f"missing {manager_binary}"})
|
|
158
|
+
continue
|
|
159
|
+
if tool.get("requires_git") and which("git") is None:
|
|
160
|
+
errors.append(f"{tool_id} has a Git dependency and requires git on PATH")
|
|
161
|
+
actions.append({"tool": tool_id, "action": "blocked", "detail": "missing git"})
|
|
162
|
+
continue
|
|
163
|
+
if manager == "npm":
|
|
164
|
+
if node_major is None:
|
|
165
|
+
node_major = _node_major(runner, which)
|
|
166
|
+
if node_major is None or node_major < 20:
|
|
167
|
+
errors.append(f"{tool_id} requires Node.js >=20")
|
|
168
|
+
actions.append({"tool": tool_id, "action": "blocked", "detail": "Node.js >=20 is required"})
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
spec = tool.get("install_spec")
|
|
172
|
+
if not spec:
|
|
173
|
+
errors.append(f"{tool_id} has no install specification")
|
|
174
|
+
actions.append({"tool": tool_id, "action": "blocked", "detail": "missing install specification"})
|
|
175
|
+
continue
|
|
176
|
+
command = ["uv", "tool", "install", spec] if manager == "uv" else ["npm", "install", "--global", spec]
|
|
177
|
+
actions.append({"tool": tool_id, "action": "install", "command": command})
|
|
178
|
+
|
|
179
|
+
manual = [
|
|
180
|
+
{"tool": name, "action": "manual", "detail": tool_map.get(name, {}).get("manual_note", "manual setup required")}
|
|
181
|
+
for name in profile.get("manual", [])
|
|
182
|
+
]
|
|
183
|
+
actions.extend(manual)
|
|
184
|
+
return {
|
|
185
|
+
"profile": profile_name,
|
|
186
|
+
"resolved_profile": resolved_name,
|
|
187
|
+
"platform": host,
|
|
188
|
+
"environment": selected_environment,
|
|
189
|
+
"runtime": manifest["environments"][selected_environment].get("runtime"),
|
|
190
|
+
"dry_run": True,
|
|
191
|
+
"actions": actions,
|
|
192
|
+
"errors": errors,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def apply_plan(plan: dict[str, Any], runner: Callable[..., Any] = subprocess.run) -> dict[str, Any]:
|
|
197
|
+
if plan["errors"]:
|
|
198
|
+
raise DevKitError("preflight failed: " + "; ".join(plan["errors"]))
|
|
199
|
+
completed: list[str] = []
|
|
200
|
+
for action in plan["actions"]:
|
|
201
|
+
if action["action"] != "install":
|
|
202
|
+
continue
|
|
203
|
+
try:
|
|
204
|
+
result = runner(action["command"], check=False)
|
|
205
|
+
except OSError as exc:
|
|
206
|
+
raise DevKitError(f"failed to start {action['tool']}: {exc}") from exc
|
|
207
|
+
if result.returncode:
|
|
208
|
+
raise DevKitError(
|
|
209
|
+
f"install failed for {action['tool']} (exit {result.returncode}); "
|
|
210
|
+
f"completed before failure: {completed}"
|
|
211
|
+
)
|
|
212
|
+
completed.append(action["tool"])
|
|
213
|
+
return {"profile": plan["profile"], "installed": completed, "actions": plan["actions"]}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Update installed tools against the remote devkit manifest.
|
|
2
|
+
|
|
3
|
+
The bundled ``manifest.json`` is a snapshot: every install spec is pinned to a
|
|
4
|
+
PyPI version or a GitHub tarball SHA at export time, so installs never move by
|
|
5
|
+
themselves. This module fetches the manifest published in the devin-devkit
|
|
6
|
+
repository (always the newest registry export) and computes a plan to bring
|
|
7
|
+
installed tools up to date.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
from devin_devkit.installer import DevKitError
|
|
22
|
+
|
|
23
|
+
REMOTE_MANIFEST_URL = (
|
|
24
|
+
"https://raw.githubusercontent.com/Icaro0310/devin-devkit/main/"
|
|
25
|
+
"packages/devkit/src/devin_devkit/manifest.json"
|
|
26
|
+
)
|
|
27
|
+
FETCH_TIMEOUT = 15
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch_remote_manifest(
|
|
31
|
+
url: str = REMOTE_MANIFEST_URL,
|
|
32
|
+
*,
|
|
33
|
+
opener: Callable[..., Any] | None = None,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Download and parse the published manifest; raise DevKitError on failure."""
|
|
36
|
+
open_fn = opener or urllib.request.urlopen
|
|
37
|
+
try:
|
|
38
|
+
with open_fn(url, timeout=FETCH_TIMEOUT) as response:
|
|
39
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
40
|
+
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
|
41
|
+
raise DevKitError(f"cannot fetch remote manifest: {exc}") from exc
|
|
42
|
+
if data.get("schema") != "devin-devkit-manifest/0.1":
|
|
43
|
+
raise DevKitError("remote manifest has an unexpected schema")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def installed_uv_tools(
|
|
48
|
+
runner: Callable[..., Any] = subprocess.run,
|
|
49
|
+
) -> dict[str, str]:
|
|
50
|
+
"""Return {package: version} from ``uv tool list``."""
|
|
51
|
+
try:
|
|
52
|
+
result = runner(
|
|
53
|
+
["uv", "tool", "list"], capture_output=True, text=True, check=False
|
|
54
|
+
)
|
|
55
|
+
except OSError:
|
|
56
|
+
return {}
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
return {}
|
|
59
|
+
installed: dict[str, str] = {}
|
|
60
|
+
for line in (result.stdout or "").splitlines():
|
|
61
|
+
if not line.strip() or line.startswith("-"):
|
|
62
|
+
continue
|
|
63
|
+
name, _, version = line.strip().partition(" ")
|
|
64
|
+
if name:
|
|
65
|
+
installed[name] = version.lstrip("v")
|
|
66
|
+
return installed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import tomllib
|
|
71
|
+
except ImportError: # Python 3.10 — declared as a conditional dependency
|
|
72
|
+
import tomli as tomllib
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _uv_tools_dir() -> Path:
|
|
76
|
+
override = os.environ.get("UV_TOOL_DIR")
|
|
77
|
+
return (
|
|
78
|
+
Path(override)
|
|
79
|
+
if override
|
|
80
|
+
else Path.home() / ".local" / "share" / "uv" / "tools"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def installed_uv_specs(
|
|
85
|
+
tools_dir: str | Path | None = None,
|
|
86
|
+
) -> dict[str, str]:
|
|
87
|
+
"""Return {package: installed requirement} parsed from uv receipts.
|
|
88
|
+
|
|
89
|
+
``uv tool list`` reports only name and version; the per-tool
|
|
90
|
+
``uv-receipt.toml`` also records the requirement, including the
|
|
91
|
+
source URL for non-PyPI installs. That lets the updater detect a
|
|
92
|
+
channel migration (git archive -> PyPI) even when the version number
|
|
93
|
+
did not change.
|
|
94
|
+
"""
|
|
95
|
+
root = Path(tools_dir) if tools_dir else _uv_tools_dir()
|
|
96
|
+
specs: dict[str, str] = {}
|
|
97
|
+
if not root.is_dir():
|
|
98
|
+
return specs
|
|
99
|
+
for receipt in root.glob("*/uv-receipt.toml"):
|
|
100
|
+
try:
|
|
101
|
+
data = tomllib.loads(receipt.read_text(encoding="utf-8"))
|
|
102
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
103
|
+
continue
|
|
104
|
+
for req in data.get("tool", {}).get("requirements", []):
|
|
105
|
+
if not isinstance(req, dict):
|
|
106
|
+
continue
|
|
107
|
+
name = req.get("name")
|
|
108
|
+
if not name:
|
|
109
|
+
continue
|
|
110
|
+
specs[name] = req.get("url") or f"{name}{req.get('specifier', '')}"
|
|
111
|
+
return specs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _spec_channel(spec: str) -> str:
|
|
115
|
+
"""Compare-key for install specs: PyPI installs collapse to 'pypi',
|
|
116
|
+
URL installs compare by exact URL."""
|
|
117
|
+
if re.match(r"(https?://|git\+)", spec):
|
|
118
|
+
return f"url:{spec}"
|
|
119
|
+
return "pypi"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def installed_npm_tools(
|
|
123
|
+
runner: Callable[..., Any] = subprocess.run,
|
|
124
|
+
) -> dict[str, str]:
|
|
125
|
+
"""Return {package: version} from ``npm ls -g --depth=0 --json``."""
|
|
126
|
+
try:
|
|
127
|
+
result = runner(
|
|
128
|
+
["npm", "ls", "--global", "--depth=0", "--json"],
|
|
129
|
+
capture_output=True, text=True, check=False,
|
|
130
|
+
)
|
|
131
|
+
except OSError:
|
|
132
|
+
return {}
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(result.stdout or "{}")
|
|
135
|
+
except json.JSONDecodeError:
|
|
136
|
+
return {}
|
|
137
|
+
return {
|
|
138
|
+
name: meta.get("version", "")
|
|
139
|
+
for name, meta in data.get("dependencies", {}).items()
|
|
140
|
+
if isinstance(meta, dict)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_update_plan(
|
|
145
|
+
remote: dict[str, Any],
|
|
146
|
+
*,
|
|
147
|
+
installed: dict[str, str] | None = None,
|
|
148
|
+
installed_specs: dict[str, str] | None = None,
|
|
149
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
150
|
+
runner: Callable[..., Any] = subprocess.run,
|
|
151
|
+
force: bool = False,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
"""Diff installed tools against the remote manifest.
|
|
154
|
+
|
|
155
|
+
``installed`` maps package name to installed version; when None it is
|
|
156
|
+
discovered via ``uv tool list`` and ``npm ls -g``.
|
|
157
|
+
``installed_specs`` maps package name to the requirement it was
|
|
158
|
+
installed from (uv receipts); when None it is discovered via
|
|
159
|
+
``installed_uv_specs``. A same-version channel change (git archive
|
|
160
|
+
-> PyPI pin) plans a reinstall rather than reporting ``current``.
|
|
161
|
+
"""
|
|
162
|
+
if installed is None:
|
|
163
|
+
installed = installed_uv_tools(runner)
|
|
164
|
+
installed.update(installed_npm_tools(runner))
|
|
165
|
+
if installed_specs is None:
|
|
166
|
+
installed_specs = installed_uv_specs()
|
|
167
|
+
|
|
168
|
+
actions: list[dict[str, Any]] = []
|
|
169
|
+
for tool in remote.get("tools", []):
|
|
170
|
+
tool_id = tool["id"]
|
|
171
|
+
manager = tool.get("manager")
|
|
172
|
+
if manager == "manual" or tool.get("status") == "manual":
|
|
173
|
+
continue
|
|
174
|
+
commands = tool.get("commands", [])
|
|
175
|
+
on_path = any(which(command) is not None for command in commands)
|
|
176
|
+
if tool["package"] not in installed and not on_path:
|
|
177
|
+
actions.append({"tool": tool_id, "action": "not-installed"})
|
|
178
|
+
continue
|
|
179
|
+
spec = tool.get("install_spec")
|
|
180
|
+
if not spec:
|
|
181
|
+
actions.append({"tool": tool_id, "action": "blocked", "detail": "missing install specification"})
|
|
182
|
+
continue
|
|
183
|
+
local_version = installed.get(tool["package"], "?")
|
|
184
|
+
remote_version = tool.get("version", "?")
|
|
185
|
+
if not force and local_version == remote_version:
|
|
186
|
+
installed_spec = installed_specs.get(tool["package"])
|
|
187
|
+
if (
|
|
188
|
+
manager == "uv"
|
|
189
|
+
and installed_spec is not None
|
|
190
|
+
and _spec_channel(installed_spec) != _spec_channel(spec)
|
|
191
|
+
):
|
|
192
|
+
actions.append({
|
|
193
|
+
"tool": tool_id,
|
|
194
|
+
"action": "update",
|
|
195
|
+
"from": local_version,
|
|
196
|
+
"to": remote_version,
|
|
197
|
+
"reason": "install source changed",
|
|
198
|
+
"command": ["uv", "tool", "install", "--force", spec],
|
|
199
|
+
})
|
|
200
|
+
else:
|
|
201
|
+
actions.append({"tool": tool_id, "action": "current", "version": local_version})
|
|
202
|
+
continue
|
|
203
|
+
command = (
|
|
204
|
+
["uv", "tool", "install", "--force", spec]
|
|
205
|
+
if manager == "uv"
|
|
206
|
+
else ["npm", "install", "--global", spec]
|
|
207
|
+
)
|
|
208
|
+
actions.append({
|
|
209
|
+
"tool": tool_id,
|
|
210
|
+
"action": "update",
|
|
211
|
+
"from": local_version,
|
|
212
|
+
"to": remote_version,
|
|
213
|
+
"command": command,
|
|
214
|
+
})
|
|
215
|
+
return {
|
|
216
|
+
"registry_version": remote.get("registry_version"),
|
|
217
|
+
"generated": remote.get("generated"),
|
|
218
|
+
"actions": actions,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def apply_update_plan(
|
|
223
|
+
plan: dict[str, Any],
|
|
224
|
+
runner: Callable[..., Any] = subprocess.run,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
updated: list[str] = []
|
|
227
|
+
failed: list[str] = []
|
|
228
|
+
for action in plan["actions"]:
|
|
229
|
+
if action["action"] != "update":
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
result = runner(action["command"], check=False)
|
|
233
|
+
except OSError:
|
|
234
|
+
result = None
|
|
235
|
+
if result is None or result.returncode:
|
|
236
|
+
failed.append(action["tool"])
|
|
237
|
+
else:
|
|
238
|
+
updated.append(action["tool"])
|
|
239
|
+
return {"updated": updated, "failed": failed}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def freshness_hint(
|
|
243
|
+
manifest: dict[str, Any],
|
|
244
|
+
*,
|
|
245
|
+
opener: Callable[..., Any] | None = None,
|
|
246
|
+
) -> str | None:
|
|
247
|
+
"""One-line hint when the remote registry is newer than the bundled manifest.
|
|
248
|
+
|
|
249
|
+
Never raises: network or parse problems simply return None.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
remote = fetch_remote_manifest(opener=opener)
|
|
253
|
+
except DevKitError:
|
|
254
|
+
return None
|
|
255
|
+
local_version = manifest.get("registry_version", 0)
|
|
256
|
+
remote_version = remote.get("registry_version", 0)
|
|
257
|
+
if remote_version > local_version:
|
|
258
|
+
return (
|
|
259
|
+
f"registry v{remote_version} is available "
|
|
260
|
+
f"(bundled: v{local_version}); run 'devin-devkit update'"
|
|
261
|
+
)
|
|
262
|
+
return None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
import devin_devkit.cli as cli
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_profiles_and_list_use_bundled_registry(capsys):
|
|
10
|
+
assert cli.main(["profiles"]) == 0
|
|
11
|
+
profiles = capsys.readouterr().out
|
|
12
|
+
assert "qa: QA" in profiles
|
|
13
|
+
assert "full: Full" in profiles
|
|
14
|
+
|
|
15
|
+
assert cli.main(["list"]) == 0
|
|
16
|
+
catalog = capsys.readouterr().out
|
|
17
|
+
assert "19 first-party Devin tools" in catalog
|
|
18
|
+
assert "devin-office" in catalog
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_install_defaults_to_dry_run(monkeypatch, capsys):
|
|
22
|
+
plan = {
|
|
23
|
+
"profile": "qa",
|
|
24
|
+
"resolved_profile": "qa",
|
|
25
|
+
"platform": "linux",
|
|
26
|
+
"environment": "linux",
|
|
27
|
+
"runtime": "extended",
|
|
28
|
+
"dry_run": True,
|
|
29
|
+
"actions": [{
|
|
30
|
+
"tool": "devin-doctor",
|
|
31
|
+
"action": "install",
|
|
32
|
+
"command": ["uv", "tool", "install", "devin-doctor==0.1.0"],
|
|
33
|
+
}],
|
|
34
|
+
"errors": [],
|
|
35
|
+
}
|
|
36
|
+
monkeypatch.setattr(cli, "build_plan", lambda *args, **kwargs: plan)
|
|
37
|
+
monkeypatch.setattr(cli, "apply_plan", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("must not apply")))
|
|
38
|
+
|
|
39
|
+
assert cli.main(["install", "qa"]) == 0
|
|
40
|
+
output = capsys.readouterr().out
|
|
41
|
+
assert "Dry run only" in output
|
|
42
|
+
assert "uv tool install devin-doctor==0.1.0" in output
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_apply_requires_explicit_flag_and_executes_plan(monkeypatch, capsys):
|
|
46
|
+
plan = {
|
|
47
|
+
"profile": "qa",
|
|
48
|
+
"resolved_profile": "qa",
|
|
49
|
+
"platform": "linux",
|
|
50
|
+
"environment": "linux",
|
|
51
|
+
"runtime": "extended",
|
|
52
|
+
"dry_run": True,
|
|
53
|
+
"actions": [{"tool": "devin-doctor", "action": "install", "command": ["uv", "tool", "install", "devin-doctor==0.1.0"]}],
|
|
54
|
+
"errors": [],
|
|
55
|
+
}
|
|
56
|
+
monkeypatch.setattr(cli, "build_plan", lambda *args, **kwargs: plan)
|
|
57
|
+
monkeypatch.setattr(cli, "apply_plan", lambda value: {"profile": value["profile"], "installed": ["devin-doctor"], "actions": value["actions"]})
|
|
58
|
+
|
|
59
|
+
assert cli.main(["install", "qa", "--apply", "--json"]) == 0
|
|
60
|
+
output = json.loads(capsys.readouterr().out)
|
|
61
|
+
assert output["installed"] == ["devin-doctor"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_apply_preflight_error_returns_without_installs(monkeypatch, capsys):
|
|
65
|
+
plan = {
|
|
66
|
+
"profile": "full",
|
|
67
|
+
"resolved_profile": "full",
|
|
68
|
+
"platform": "windows",
|
|
69
|
+
"environment": "corporate_windows",
|
|
70
|
+
"runtime": "local-only",
|
|
71
|
+
"dry_run": True,
|
|
72
|
+
"actions": [{"tool": "devin-bridge", "action": "blocked", "detail": "missing node"}],
|
|
73
|
+
"errors": ["devin-bridge requires Node.js >=20"],
|
|
74
|
+
}
|
|
75
|
+
monkeypatch.setattr(cli, "build_plan", lambda *args, **kwargs: plan)
|
|
76
|
+
monkeypatch.setattr(cli, "apply_plan", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("must not apply")))
|
|
77
|
+
|
|
78
|
+
assert cli.main(["install", "full", "--apply"]) == 2
|
|
79
|
+
assert "requires Node.js >=20" in capsys.readouterr().err
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from devin_devkit import installer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def supported_environments():
|
|
12
|
+
return {
|
|
13
|
+
"linux": {"supported": True, "runtime": "extended", "delegation": "optional", "external_dependencies": False},
|
|
14
|
+
"personal_windows": {"supported": True, "runtime": "extended", "delegation": "optional", "external_dependencies": False},
|
|
15
|
+
"corporate_windows": {"supported": True, "runtime": "local-only", "delegation": "forbidden", "external_dependencies": False},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sample_manifest():
|
|
20
|
+
return {
|
|
21
|
+
"schema": "devin-devkit-manifest/0.1",
|
|
22
|
+
"supported_platforms": ["windows", "linux"],
|
|
23
|
+
"planned_platforms": ["macos"],
|
|
24
|
+
"environments": {
|
|
25
|
+
"linux": {"label": "Linux", "runtime": "extended", "delegation": "optional", "local_only": False, "external_compute": True, "devkit": "linux"},
|
|
26
|
+
"personal_windows": {"label": "Personal Windows", "runtime": "extended", "delegation": "optional", "local_only": False, "external_compute": True, "devkit": "personal-windows"},
|
|
27
|
+
"corporate_windows": {"label": "Corporate Windows", "runtime": "local-only", "delegation": "forbidden", "local_only": True, "external_compute": False, "devkit": "corporate-windows"},
|
|
28
|
+
},
|
|
29
|
+
"git_required_tools": ["devin-history"],
|
|
30
|
+
"catalog": {"tool_count": 2, "hub_count": 1, "related_count": 0, "entry_count": 3},
|
|
31
|
+
"profiles": {
|
|
32
|
+
"qa": {"label": "QA", "description": "test", "tools": ["devin-doctor"]},
|
|
33
|
+
"all": {"label": "All", "description": "all", "tools": ["devin-doctor"], "alias_of": "qa"},
|
|
34
|
+
"full": {"label": "Full", "description": "full", "tools": ["devin-doctor", "devin-bridge", "devin-history"], "manual": ["devin-office"]},
|
|
35
|
+
},
|
|
36
|
+
"tools": [
|
|
37
|
+
{
|
|
38
|
+
"id": "devin-doctor", "manager": "uv", "source": "pypi",
|
|
39
|
+
"package": "devin-doctor", "version": "0.1.0",
|
|
40
|
+
"commands": ["devin-doctor"], "runtime": "python>=3.10",
|
|
41
|
+
"platforms": ["windows", "linux"], "status": "published",
|
|
42
|
+
"environments": supported_environments(),
|
|
43
|
+
"install_spec": "devin-doctor==0.1.0", "manual_note": None,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "devin-bridge", "manager": "npm", "source": "github",
|
|
47
|
+
"package": "@icaro0310/devin-bridge", "version": "0.1.0",
|
|
48
|
+
"commands": ["devin-bridge"], "runtime": "node>=20",
|
|
49
|
+
"platforms": ["windows", "linux"], "status": "source",
|
|
50
|
+
"environments": supported_environments(),
|
|
51
|
+
"install_spec": "https://github.com/Icaro0310/devin-bridge/archive/abc.tar.gz", "requires_git": False, "manual_note": None,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "devin-history", "manager": "uv", "source": "github",
|
|
55
|
+
"package": "devin-history", "version": "0.1.0",
|
|
56
|
+
"commands": ["devin-history"], "runtime": "python>=3.10",
|
|
57
|
+
"platforms": ["windows", "linux"], "status": "source",
|
|
58
|
+
"environments": supported_environments(),
|
|
59
|
+
"install_spec": "https://github.com/Icaro0310/devin-history/archive/def.tar.gz", "requires_git": True,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "devin-office", "manager": "manual", "source": "manual",
|
|
63
|
+
"package": "devin-office", "version": "0.1.0",
|
|
64
|
+
"commands": ["python daemon.py"], "runtime": "python>=3.10",
|
|
65
|
+
"platforms": ["windows", "linux"], "status": "manual",
|
|
66
|
+
"environments": supported_environments(),
|
|
67
|
+
"install_spec": None, "manual_note": "source-only service",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_platform_detection_is_os_aware():
|
|
74
|
+
assert installer.platform_name("Windows") == "windows"
|
|
75
|
+
assert installer.platform_name("Linux") == "linux"
|
|
76
|
+
assert installer.platform_name("Darwin") == "macos"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_alias_resolves_and_plan_has_no_side_effects():
|
|
80
|
+
calls = []
|
|
81
|
+
which = lambda name: f"/tools/{name}" if name in {"uv", "git"} else None
|
|
82
|
+
runner = lambda *args, **kwargs: calls.append(args[0]) or SimpleNamespace(stdout="", returncode=0)
|
|
83
|
+
|
|
84
|
+
plan = installer.build_plan(sample_manifest(), "all", system="linux", which=which, runner=runner)
|
|
85
|
+
|
|
86
|
+
assert plan["resolved_profile"] == "qa"
|
|
87
|
+
assert plan["actions"][0]["command"] == ["uv", "tool", "install", "devin-doctor==0.1.0"]
|
|
88
|
+
assert calls == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_manual_service_is_reported_not_silently_installed():
|
|
92
|
+
which = lambda name: f"/tools/{name}" if name == "uv" else None
|
|
93
|
+
plan = installer.build_plan(sample_manifest(), "full", system="linux", which=which)
|
|
94
|
+
|
|
95
|
+
office = next(a for a in plan["actions"] if a["tool"] == "devin-office")
|
|
96
|
+
assert office["action"] == "manual"
|
|
97
|
+
assert office["detail"] == "source-only service"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_git_sources_block_before_any_installs_when_git_is_missing():
|
|
101
|
+
which = lambda name: f"/tools/{name}" if name in {"uv", "npm", "node"} else None
|
|
102
|
+
runner = lambda *args, **kwargs: SimpleNamespace(stdout="v22.0.0", returncode=0)
|
|
103
|
+
plan = installer.build_plan(sample_manifest(), "full", system="linux", which=which, runner=runner)
|
|
104
|
+
|
|
105
|
+
assert plan["errors"] == ["devin-history has a Git dependency and requires git on PATH"]
|
|
106
|
+
with pytest.raises(installer.DevKitError, match="preflight failed"):
|
|
107
|
+
installer.apply_plan(plan, runner=lambda *args, **kwargs: pytest.fail("must not run"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_github_archive_source_does_not_require_git():
|
|
111
|
+
which = lambda name: f"/tools/{name}" if name in {"uv", "npm", "node"} else None
|
|
112
|
+
runner = lambda *args, **kwargs: SimpleNamespace(stdout="v22.0.0", returncode=0)
|
|
113
|
+
manifest = sample_manifest()
|
|
114
|
+
manifest["profiles"]["qa"]["tools"] = ["devin-bridge"]
|
|
115
|
+
plan = installer.build_plan(manifest, "qa", system="linux", which=which, runner=runner)
|
|
116
|
+
|
|
117
|
+
assert plan["errors"] == []
|
|
118
|
+
bridge = plan["actions"][0]
|
|
119
|
+
assert bridge["action"] == "install"
|
|
120
|
+
assert bridge["command"][-1].startswith("https://github.com/")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_node_runtime_is_checked_before_npm_install():
|
|
124
|
+
which = lambda name: f"/tools/{name}" if name in {"uv", "npm", "git", "node"} else None
|
|
125
|
+
runner = lambda *args, **kwargs: SimpleNamespace(stdout="v18.20.0", returncode=0)
|
|
126
|
+
plan = installer.build_plan(sample_manifest(), "full", system="windows", which=which, runner=runner)
|
|
127
|
+
|
|
128
|
+
assert "devin-bridge requires Node.js >=20" in plan["errors"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_existing_commands_are_not_overwritten():
|
|
132
|
+
which = lambda name: f"/tools/{name}" if name in {"uv", "devin-doctor"} else None
|
|
133
|
+
plan = installer.build_plan(sample_manifest(), "qa", system="windows", which=which)
|
|
134
|
+
|
|
135
|
+
assert plan["actions"] == [{"tool": "devin-doctor", "action": "preexisting", "detail": "commands are on PATH; versions are not verified", "commands": ["devin-doctor"]}]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_partial_path_collision_blocks_install():
|
|
139
|
+
which = lambda name: f"/tools/{name}" if name in {"uv", "devin-memory"} else None
|
|
140
|
+
manifest = sample_manifest()
|
|
141
|
+
manifest["tools"][0]["commands"] = ["devin-memory", "devin-learning"]
|
|
142
|
+
plan = installer.build_plan(manifest, "qa", system="linux", which=which)
|
|
143
|
+
|
|
144
|
+
assert plan["errors"] and "partial PATH collision" in plan["errors"][0]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_unsupported_macos_is_explicitly_rejected():
|
|
148
|
+
with pytest.raises(installer.DevKitError, match="not supported"):
|
|
149
|
+
installer.build_plan(sample_manifest(), "qa", system="Darwin")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_apply_runs_only_install_actions():
|
|
153
|
+
calls = []
|
|
154
|
+
plan = {"profile": "qa", "errors": [], "actions": [
|
|
155
|
+
{"tool": "devin-doctor", "action": "install", "command": ["uv", "tool", "install", "devin-doctor==0.1.0"]},
|
|
156
|
+
{"tool": "devin-office", "action": "manual", "detail": "source-only service"},
|
|
157
|
+
]}
|
|
158
|
+
runner = lambda cmd, **kwargs: calls.append(cmd) or SimpleNamespace(returncode=0)
|
|
159
|
+
|
|
160
|
+
result = installer.apply_plan(plan, runner=runner)
|
|
161
|
+
|
|
162
|
+
assert calls == [["uv", "tool", "install", "devin-doctor==0.1.0"]]
|
|
163
|
+
assert result["installed"] == ["devin-doctor"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_apply_fails_closed_on_preflight_errors():
|
|
167
|
+
plan = {"profile": "full", "errors": ["missing uv"], "actions": []}
|
|
168
|
+
with pytest.raises(installer.DevKitError, match="preflight failed"):
|
|
169
|
+
installer.apply_plan(plan, runner=lambda *args, **kwargs: pytest.fail("must not run"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_load_manifest_from_file(tmp_path: Path):
|
|
173
|
+
manifest = sample_manifest()
|
|
174
|
+
path = tmp_path / "manifest.json"
|
|
175
|
+
path.write_text(__import__("json").dumps(manifest), encoding="utf-8")
|
|
176
|
+
assert installer.load_manifest(path)["schema"] == "devin-devkit-manifest/0.1"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_explicit_linux_environment_is_extended():
|
|
180
|
+
plan = installer.build_plan(sample_manifest(), "all", system="Linux", environment="linux", which=lambda c: "/bin/uv" if c == "uv" else None)
|
|
181
|
+
assert plan["environment"] == "linux"
|
|
182
|
+
assert plan["runtime"] == "extended"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_windows_defaults_to_personal_windows_environment():
|
|
186
|
+
plan = installer.build_plan(sample_manifest(), "qa", system="Windows", which=lambda c: "C:/uv.exe" if c == "uv" else None)
|
|
187
|
+
assert plan["environment"] == "personal_windows"
|
|
188
|
+
assert plan["runtime"] == "extended"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_corporate_windows_is_explicit_and_local_only():
|
|
192
|
+
plan = installer.build_plan(sample_manifest(), "qa", system="Windows", environment="corporate-windows", which=lambda c: "C:/uv.exe" if c == "uv" else None)
|
|
193
|
+
assert plan["environment"] == "corporate_windows"
|
|
194
|
+
assert plan["runtime"] == "local-only"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_environment_must_match_host_platform():
|
|
198
|
+
with pytest.raises(installer.DevKitError, match="cannot run on host platform"):
|
|
199
|
+
installer.build_plan(sample_manifest(), "qa", system="Linux", environment="corporate-windows")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_corporate_windows_rejects_external_dependencies():
|
|
203
|
+
manifest = sample_manifest()
|
|
204
|
+
manifest["tools"][0]["environments"]["corporate_windows"] = {
|
|
205
|
+
"supported": True,
|
|
206
|
+
"runtime": "local-only",
|
|
207
|
+
"delegation": "optional",
|
|
208
|
+
"external_dependencies": True,
|
|
209
|
+
}
|
|
210
|
+
plan = installer.build_plan(manifest, "all", system="Windows", environment="corporate-windows", which=lambda c: "C:/uv.exe" if c == "uv" else None)
|
|
211
|
+
assert plan["errors"] == ["devin-doctor violates the corporate Windows local-only guarantee"]
|
|
212
|
+
assert plan["actions"][0]["action"] == "blocked"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_unsupported_environment_uses_registry_reason():
|
|
216
|
+
manifest = sample_manifest()
|
|
217
|
+
manifest["tools"][0]["environments"]["corporate_windows"] = {
|
|
218
|
+
"supported": False,
|
|
219
|
+
"runtime": "unavailable",
|
|
220
|
+
"delegation": "forbidden",
|
|
221
|
+
"external_dependencies": True,
|
|
222
|
+
"reason": "requires an external runtime",
|
|
223
|
+
}
|
|
224
|
+
plan = installer.build_plan(manifest, "all", system="Windows", environment="corporate-windows", which=lambda c: "C:/uv.exe" if c == "uv" else None)
|
|
225
|
+
assert plan["errors"] == ["devin-doctor is unsupported in corporate_windows: requires an external runtime"]
|
|
226
|
+
assert plan["actions"][0]["detail"] == "requires an external runtime"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from devin_devkit import updater
|
|
8
|
+
from devin_devkit.installer import DevKitError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def remote_manifest():
|
|
12
|
+
return {
|
|
13
|
+
"schema": "devin-devkit-manifest/0.1",
|
|
14
|
+
"registry_version": 11,
|
|
15
|
+
"generated": "2026-10-09",
|
|
16
|
+
"tools": [
|
|
17
|
+
{
|
|
18
|
+
"id": "devin-doctor", "manager": "uv", "source": "pypi",
|
|
19
|
+
"package": "devin-doctor", "version": "0.2.0",
|
|
20
|
+
"commands": ["devin-doctor"], "status": "published",
|
|
21
|
+
"install_spec": "devin-doctor==0.2.0",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "devin-history", "manager": "uv", "source": "github",
|
|
25
|
+
"package": "devin-history", "version": "0.1.0",
|
|
26
|
+
"commands": ["devin-history"], "status": "source",
|
|
27
|
+
"install_spec": "https://example.com/history.tar.gz",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "devin-bridge", "manager": "npm", "source": "github",
|
|
31
|
+
"package": "@icaro0310/devin-bridge", "version": "0.2.0",
|
|
32
|
+
"commands": ["devin-bridge"], "status": "source",
|
|
33
|
+
"install_spec": "https://example.com/bridge.tar.gz",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "devin-office", "manager": "manual", "source": "manual",
|
|
37
|
+
"package": "devin-office", "version": "0.1.0",
|
|
38
|
+
"commands": ["devin-office"], "status": "manual",
|
|
39
|
+
"install_spec": None,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def which_all(command):
|
|
46
|
+
return f"/usr/bin/{command}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def which_none(command):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_plan_updates_only_installed_outdated_tools():
|
|
54
|
+
installed = {
|
|
55
|
+
"devin-doctor": "0.1.0", # older than remote 0.2.0 -> update
|
|
56
|
+
"devin-history": "0.1.0", # same version -> current
|
|
57
|
+
"@icaro0310/devin-bridge": "0.1.0", # npm, older -> update
|
|
58
|
+
}
|
|
59
|
+
plan = updater.build_update_plan(remote_manifest(), installed=installed, installed_specs={}, which=which_none)
|
|
60
|
+
actions = {a["tool"]: a for a in plan["actions"]}
|
|
61
|
+
|
|
62
|
+
assert actions["devin-doctor"]["action"] == "update"
|
|
63
|
+
assert actions["devin-doctor"]["command"] == ["uv", "tool", "install", "--force", "devin-doctor==0.2.0"]
|
|
64
|
+
assert actions["devin-history"]["action"] == "current"
|
|
65
|
+
assert actions["devin-bridge"]["action"] == "update"
|
|
66
|
+
assert actions["devin-bridge"]["command"][0] == "npm"
|
|
67
|
+
assert "devin-office" not in actions # manual tools are never touched
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_plan_detects_tools_on_path_not_in_uv_list():
|
|
71
|
+
plan = updater.build_update_plan(remote_manifest(), installed={}, installed_specs={}, which=which_all)
|
|
72
|
+
actions = {a["tool"]: a["action"] for a in plan["actions"]}
|
|
73
|
+
assert actions["devin-doctor"] == "update" # on PATH but version unknown "?"
|
|
74
|
+
assert actions["devin-history"] == "update"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_plan_skips_absent_tools():
|
|
78
|
+
plan = updater.build_update_plan(remote_manifest(), installed={}, installed_specs={}, which=which_none)
|
|
79
|
+
assert all(a["action"] == "not-installed" for a in plan["actions"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_force_reinstalls_current_tools():
|
|
83
|
+
installed = {"devin-history": "0.1.0"}
|
|
84
|
+
plan = updater.build_update_plan(remote_manifest(), installed=installed, installed_specs={}, which=which_none, force=True)
|
|
85
|
+
history = next(a for a in plan["actions"] if a["tool"] == "devin-history")
|
|
86
|
+
assert history["action"] == "update"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_apply_reports_failures():
|
|
90
|
+
def runner(command, **kwargs):
|
|
91
|
+
if "history" in " ".join(command):
|
|
92
|
+
return SimpleNamespace(returncode=1)
|
|
93
|
+
return SimpleNamespace(returncode=0)
|
|
94
|
+
|
|
95
|
+
plan = updater.build_update_plan(
|
|
96
|
+
remote_manifest(),
|
|
97
|
+
installed={"devin-doctor": "0.1.0", "devin-history": "0.0.9"},
|
|
98
|
+
installed_specs={},
|
|
99
|
+
which=which_none,
|
|
100
|
+
)
|
|
101
|
+
result = updater.apply_update_plan(plan, runner=runner)
|
|
102
|
+
assert result["updated"] == ["devin-doctor"]
|
|
103
|
+
assert result["failed"] == ["devin-history"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_uv_tool_list_parsing():
|
|
107
|
+
def runner(command, **kwargs):
|
|
108
|
+
return SimpleNamespace(returncode=0, stdout="devin-doctor v0.1.0\n- devin-doctor\n")
|
|
109
|
+
|
|
110
|
+
assert updater.installed_uv_tools(runner) == {"devin-doctor": "0.1.0"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_freshness_hint_compares_registry_versions():
|
|
114
|
+
local = {"registry_version": 10}
|
|
115
|
+
remote = remote_manifest() # registry_version 11
|
|
116
|
+
|
|
117
|
+
class FakeResponse:
|
|
118
|
+
def read(self):
|
|
119
|
+
import json
|
|
120
|
+
return json.dumps(remote).encode()
|
|
121
|
+
|
|
122
|
+
def __enter__(self):
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *args):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
hint = updater.freshness_hint(local, opener=lambda url, timeout: FakeResponse())
|
|
129
|
+
assert "registry v11" in hint
|
|
130
|
+
assert "update" in hint
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_freshness_hint_is_silent_when_current_or_offline():
|
|
134
|
+
local = {"registry_version": 99}
|
|
135
|
+
assert updater.freshness_hint(local, opener=lambda *a, **k: (_ for _ in ()).throw(OSError())) is None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_same_version_channel_change_plans_reinstall():
|
|
139
|
+
"""GitHub-archive install at the same version must be migrated to
|
|
140
|
+
the PyPI pin — version equality alone is not 'current'."""
|
|
141
|
+
manifest = remote_manifest()
|
|
142
|
+
manifest["tools"][0] = {
|
|
143
|
+
"id": "devin-evals", "manager": "uv", "source": "pypi",
|
|
144
|
+
"package": "devin-evals", "version": "0.2.0",
|
|
145
|
+
"commands": ["devin-evals"], "status": "published",
|
|
146
|
+
"install_spec": "devin-evals==0.2.0",
|
|
147
|
+
}
|
|
148
|
+
specs = {"devin-evals": "https://github.com/Icaro0310/devin-evals/archive/abc123.tar.gz"}
|
|
149
|
+
plan = updater.build_update_plan(
|
|
150
|
+
manifest,
|
|
151
|
+
installed={"devin-evals": "0.2.0"},
|
|
152
|
+
installed_specs=specs,
|
|
153
|
+
which=which_none,
|
|
154
|
+
)
|
|
155
|
+
action = next(a for a in plan["actions"] if a["tool"] == "devin-evals")
|
|
156
|
+
assert action["action"] == "update"
|
|
157
|
+
assert action["reason"] == "install source changed"
|
|
158
|
+
assert action["command"] == ["uv", "tool", "install", "--force", "devin-evals==0.2.0"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_same_version_same_source_stays_current():
|
|
162
|
+
manifest = remote_manifest()
|
|
163
|
+
specs = {"devin-history": "https://example.com/history.tar.gz"}
|
|
164
|
+
plan = updater.build_update_plan(
|
|
165
|
+
manifest,
|
|
166
|
+
installed={"devin-history": "0.1.0"},
|
|
167
|
+
installed_specs=specs,
|
|
168
|
+
which=which_none,
|
|
169
|
+
)
|
|
170
|
+
action = next(a for a in plan["actions"] if a["tool"] == "devin-history")
|
|
171
|
+
assert action["action"] == "current"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_missing_receipt_falls_back_to_version_compare():
|
|
175
|
+
plan = updater.build_update_plan(
|
|
176
|
+
remote_manifest(),
|
|
177
|
+
installed={"devin-history": "0.1.0"},
|
|
178
|
+
installed_specs={},
|
|
179
|
+
which=which_none,
|
|
180
|
+
)
|
|
181
|
+
action = next(a for a in plan["actions"] if a["tool"] == "devin-history")
|
|
182
|
+
assert action["action"] == "current"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_installed_uv_specs_parses_receipts(tmp_path):
|
|
186
|
+
(tmp_path / "devin-evals").mkdir()
|
|
187
|
+
(tmp_path / "devin-evals" / "uv-receipt.toml").write_text(
|
|
188
|
+
'[tool]\nrequirements = [{ name = "devin-evals", url = '
|
|
189
|
+
'"https://example.com/evals.tar.gz" }]\n'
|
|
190
|
+
)
|
|
191
|
+
(tmp_path / "devin-doctor").mkdir()
|
|
192
|
+
(tmp_path / "devin-doctor" / "uv-receipt.toml").write_text(
|
|
193
|
+
'[tool]\nrequirements = [{ name = "devin-doctor", specifier = "==0.2.0" }]\n'
|
|
194
|
+
)
|
|
195
|
+
specs = updater.installed_uv_specs(tmp_path)
|
|
196
|
+
assert specs["devin-evals"] == "https://example.com/evals.tar.gz"
|
|
197
|
+
assert specs["devin-doctor"] == "devin-doctor==0.2.0"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_installed_uv_specs_handles_field_order_and_extras(tmp_path):
|
|
201
|
+
"""Receipt fields may appear in any order with extra keys; the url
|
|
202
|
+
must still be found so archive installs aren't mislabeled PyPI."""
|
|
203
|
+
receipt = (
|
|
204
|
+
'[tool]\nrequirements = [\n'
|
|
205
|
+
' { name = "devin-evals", extras = [], '
|
|
206
|
+
'url = "https://example.com/evals.tar.gz" },\n'
|
|
207
|
+
']\nentrypoints = [\n'
|
|
208
|
+
' { name = "devin-evals", install-path = "/x/devin-evals", '
|
|
209
|
+
'from = "devin-evals" },\n]\n'
|
|
210
|
+
)
|
|
211
|
+
(tmp_path / "devin-evals").mkdir()
|
|
212
|
+
(tmp_path / "devin-evals" / "uv-receipt.toml").write_text(receipt)
|
|
213
|
+
specs = updater.installed_uv_specs(tmp_path)
|
|
214
|
+
assert specs["devin-evals"] == "https://example.com/evals.tar.gz"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_installed_uv_specs_nested_table_and_index_url(tmp_path):
|
|
218
|
+
"""A requirement with an inline metadata table or an index-url field
|
|
219
|
+
must not hide the real install source."""
|
|
220
|
+
(tmp_path / "devin-evals").mkdir()
|
|
221
|
+
(tmp_path / "devin-evals" / "uv-receipt.toml").write_text(
|
|
222
|
+
'[tool]\nrequirements = [{ name = "devin-evals", '
|
|
223
|
+
'metadata = { note = "x" }, '
|
|
224
|
+
'url = "https://example.com/evals.tar.gz" }]\n'
|
|
225
|
+
)
|
|
226
|
+
(tmp_path / "devin-doctor").mkdir()
|
|
227
|
+
(tmp_path / "devin-doctor" / "uv-receipt.toml").write_text(
|
|
228
|
+
'[tool]\nrequirements = [{ name = "devin-doctor", '
|
|
229
|
+
'specifier = "==0.2.0", index-url = "https://pypi.example/simple" }]\n'
|
|
230
|
+
)
|
|
231
|
+
specs = updater.installed_uv_specs(tmp_path)
|
|
232
|
+
assert specs["devin-evals"] == "https://example.com/evals.tar.gz"
|
|
233
|
+
assert specs["devin-doctor"] == "devin-doctor==0.2.0"
|