devin-devkit 0.1.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/__init__.py +1 -0
- devin_devkit/cli.py +128 -0
- devin_devkit/installer.py +213 -0
- devin_devkit/manifest.json +1201 -0
- devin_devkit/updater.py +262 -0
- devin_devkit-0.1.0.dist-info/METADATA +129 -0
- devin_devkit-0.1.0.dist-info/RECORD +10 -0
- devin_devkit-0.1.0.dist-info/WHEEL +5 -0
- devin_devkit-0.1.0.dist-info/entry_points.txt +2 -0
- devin_devkit-0.1.0.dist-info/top_level.txt +1 -0
devin_devkit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
devin_devkit/cli.py
ADDED
|
@@ -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"]}
|