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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: devin-devkit-workspace
3
+ Version: 0.0.0
4
+ Summary: Monorepo workspace for the devin-devkit product: devkit, skill-catalog.
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Dynamic: license-file
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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,4 @@
1
+ def test_import():
2
+ import devin_devkit
3
+
4
+ assert devin_devkit.__version__
@@ -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"