resolvescript 0.1.2__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.
Files changed (49) hide show
  1. resolve_script/__init__.py +3 -0
  2. resolve_script/analyze.py +277 -0
  3. resolve_script/cli.py +748 -0
  4. resolve_script/config.py +62 -0
  5. resolve_script/consolidate.py +604 -0
  6. resolve_script/fetch.py +90 -0
  7. resolve_script/install/__init__.py +49 -0
  8. resolve_script/install/discovery.py +57 -0
  9. resolve_script/install/installer.py +397 -0
  10. resolve_script/install/registry.py +106 -0
  11. resolve_script/manifest/__init__.py +1 -0
  12. resolve_script/manifest/json_reader.py +40 -0
  13. resolve_script/manifest/model.py +316 -0
  14. resolve_script/manifest/validation.py +81 -0
  15. resolve_script/manifest/xml_reader.py +162 -0
  16. resolve_script/package.py +103 -0
  17. resolve_script/resolver.py +204 -0
  18. resolve_script/sandbox/__init__.py +38 -0
  19. resolve_script/sandbox/api.py +393 -0
  20. resolve_script/sandbox/env.py +82 -0
  21. resolve_script/sandbox/loader.py +72 -0
  22. resolve_script/sandbox/repl.py +57 -0
  23. resolve_script/sandbox/smoke.py +104 -0
  24. resolve_script/scaffold.py +126 -0
  25. resolve_script/semver.py +236 -0
  26. resolve_script/sources/__init__.py +15 -0
  27. resolve_script/sources/archive.py +82 -0
  28. resolve_script/sources/git.py +107 -0
  29. resolve_script/sources/known.py +47 -0
  30. resolve_script/sources/release.py +55 -0
  31. resolve_script/spec.py +137 -0
  32. resolve_script/templates/extension/@NAME@/__init__.py +7 -0
  33. resolve_script/templates/extension/@NAME@/menu.py +12 -0
  34. resolve_script/templates/extension/@NAME@.py +13 -0
  35. resolve_script/templates/extension/README.md +20 -0
  36. resolve_script/templates/extension/conftest.py +13 -0
  37. resolve_script/templates/extension/manifest.json.j2 +23 -0
  38. resolve_script/templates/extension/manifest.xml.j2 +24 -0
  39. resolve_script/templates/extension/tests/test_smoke.py +26 -0
  40. resolve_script/templates/inapp/register.py +28 -0
  41. resolve_script/testing/__init__.py +6 -0
  42. resolve_script/testing/fixtures.py +47 -0
  43. resolve_script/workspace.py +66 -0
  44. resolvescript-0.1.2.dist-info/METADATA +146 -0
  45. resolvescript-0.1.2.dist-info/RECORD +49 -0
  46. resolvescript-0.1.2.dist-info/WHEEL +5 -0
  47. resolvescript-0.1.2.dist-info/entry_points.txt +2 -0
  48. resolvescript-0.1.2.dist-info/licenses/LICENSE +21 -0
  49. resolvescript-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,90 @@
1
+ """HTTP(S) download + SHA-256 integrity verification (stdlib only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import socket
7
+ import tempfile
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ class FetchError(RuntimeError):
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class Fetched:
19
+ path: Path
20
+ sha256: str
21
+ size: int
22
+ url: str
23
+
24
+
25
+ def sha256_file(path: Path) -> str:
26
+ digest = hashlib.sha256()
27
+ with path.open("rb") as handle:
28
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
29
+ digest.update(chunk)
30
+ return digest.hexdigest()
31
+
32
+
33
+ def allow_remote() -> bool:
34
+ """Whether remote downloads are allowed (default on; off via env)."""
35
+ import os
36
+
37
+ return os.environ.get("RESOLVESCRIPT_ALLOW_NETWORK", "1").lower() not in (
38
+ "0",
39
+ "false",
40
+ "no",
41
+ )
42
+
43
+
44
+ def fetch(
45
+ url: str,
46
+ *,
47
+ dest: Path,
48
+ expected_sha256: str | None = None,
49
+ timeout: float = 45.0,
50
+ ) -> Fetched:
51
+ """Download ``url`` to a fresh ``dest``; verify integrity when provided.
52
+
53
+ Raises :class:`FetchError` on HTTP errors, or if the downloaded bytes do
54
+ not match ``expected_sha256`` (the file is then removed).
55
+ """
56
+ dest.parent.mkdir(parents=True, exist_ok=True)
57
+ if dest.exists():
58
+ dest.unlink()
59
+ if not allow_remote():
60
+ raise FetchError("network downloads are disabled (RESOLVESCRIPT_ALLOW_NETWORK=0)")
61
+ previous = socket.getdefaulttimeout()
62
+ socket.setdefaulttimeout(timeout)
63
+ try:
64
+ try:
65
+ with urllib.request.urlopen(url, timeout=timeout) as response:
66
+ data = response.read()
67
+ except Exception as exc: # URLError, HTTPError, timeout…
68
+ raise FetchError(f"failed to download {url}: {exc}") from exc
69
+ finally:
70
+ socket.setdefaulttimeout(previous)
71
+ size = len(data)
72
+ digest = hashlib.sha256(data).hexdigest()
73
+ if expected_sha256 and digest != expected_sha256:
74
+ raise FetchError(
75
+ f"integrity check failed for {url}: expected sha256 "
76
+ f"{expected_sha256}, got {digest}"
77
+ )
78
+ dest.write_bytes(data)
79
+ return Fetched(path=dest, sha256=digest, size=size, url=url)
80
+
81
+
82
+ def fetch_json(
83
+ url: str, *, timeout: float = 45.0
84
+ ) -> object:
85
+ """Download a JSON document (used for the GitHub tags API in tests)."""
86
+ with tempfile.TemporaryDirectory() as tmp:
87
+ result = fetch(url, dest=Path(tmp) / "payload", timeout=timeout)
88
+ import json
89
+
90
+ return json.loads(result.path.read_text("utf-8"))
@@ -0,0 +1,49 @@
1
+ """Installer core: per-OS script roots, atomic install, registry, uninstall."""
2
+
3
+ from .discovery import default_scripts_root, resolve_scripts_root, target_dir
4
+ from .installer import (
5
+ InstalledFile,
6
+ InstallError,
7
+ InstallOptions,
8
+ InstallResult,
9
+ discover_entrypoint,
10
+ install_package,
11
+ install_project,
12
+ select_files,
13
+ uninstall_package,
14
+ )
15
+ from .registry import (
16
+ REGISTRY_REL,
17
+ SCHEMA_VERSION,
18
+ RegistryError,
19
+ add_or_update_entry,
20
+ get_extension,
21
+ read_registry,
22
+ registry_path,
23
+ remove_entry,
24
+ write_registry,
25
+ )
26
+
27
+ __all__ = [
28
+ "REGISTRY_REL",
29
+ "SCHEMA_VERSION",
30
+ "InstallError",
31
+ "InstallOptions",
32
+ "InstallResult",
33
+ "InstalledFile",
34
+ "RegistryError",
35
+ "add_or_update_entry",
36
+ "default_scripts_root",
37
+ "discover_entrypoint",
38
+ "get_extension",
39
+ "install_package",
40
+ "install_project",
41
+ "read_registry",
42
+ "registry_path",
43
+ "remove_entry",
44
+ "resolve_scripts_root",
45
+ "select_files",
46
+ "target_dir",
47
+ "uninstall_package",
48
+ "write_registry",
49
+ ]
@@ -0,0 +1,57 @@
1
+ """Per-OS DaVinci Resolve Scripts-root discovery.
2
+
3
+ The Scripts root is the parent of the well-known target folders
4
+ (``Comp``, ``Utility``, ``Tool``, …) that appear in Resolve's Workspace menus.
5
+ The standard layout is ``…/Fusion/Scripts`` on every OS.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import platform
12
+ from pathlib import Path
13
+
14
+ from ..config import ENV_SCRIPTS_ROOT
15
+ from ..manifest.model import TARGET_SUGGESTIONS, ManifestError, Target
16
+
17
+
18
+ def default_scripts_root() -> Path:
19
+ """Return the OS-default DaVinci Resolve Scripts root (no override)."""
20
+ system = platform.system()
21
+ home = Path.home()
22
+ if system == "Windows":
23
+ appdata = os.environ.get("APPDATA") or str(home)
24
+ return Path(appdata) / "Blackmagic Design" / "DaVinci Resolve" / "Fusion" / "Scripts"
25
+ if system == "Darwin":
26
+ return (
27
+ home
28
+ / "Library"
29
+ / "Application Support"
30
+ / "Blackmagic Design"
31
+ / "DaVinci Resolve"
32
+ / "Fusion"
33
+ / "Scripts"
34
+ )
35
+ # Linux and anything else
36
+ return home / ".local" / "share" / "DaVinci Resolve" / "Fusion" / "Scripts"
37
+
38
+
39
+ def resolve_scripts_root(override: str | Path | None = None) -> Path:
40
+ """Effective Scripts root: explicit override, then env var, then OS default."""
41
+ if override:
42
+ return Path(override).expanduser()
43
+ env_value = os.environ.get(ENV_SCRIPTS_ROOT, "")
44
+ if env_value.strip():
45
+ return Path(env_value).expanduser()
46
+ return default_scripts_root()
47
+
48
+
49
+ def target_dir(scripts_root: Path, target: str) -> Path:
50
+ """Validate a target name and return its folder under the Scripts root."""
51
+ if target == Target.ROOT.value:
52
+ return Path(scripts_root)
53
+ if target not in Target.valid_names():
54
+ suggestion = TARGET_SUGGESTIONS.get(target.lower())
55
+ hint = f" (did you mean '{suggestion}'?)" if suggestion else ""
56
+ raise ManifestError(f"unknown script target '{target}'{hint}")
57
+ return Path(scripts_root) / target
@@ -0,0 +1,397 @@
1
+ """Atomic install of a Resolve script / extension into a Scripts root.
2
+
3
+ Design:
4
+
5
+ - Files are selected from the package tree via ``install.include`` /
6
+ ``install.exclude`` patterns (default: everything except caches).
7
+ - A staging directory is built inside the destination's parent (same volume),
8
+ the staged entry file is ``py_compile``-checked, and only then the tree is
9
+ renamed into place. Any failure discards the stage — no partial installs.
10
+ - The registry is consulted before writing: if another extension already owns
11
+ a destination file, the install is refused unless ``force`` is set.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import py_compile
18
+ import re
19
+ import shutil
20
+ import tempfile
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ..manifest.model import Manifest, ManifestError
26
+ from .registry import add_or_update_entry, get_extension, installed_at_now, read_registry
27
+
28
+
29
+ class InstallError(Exception):
30
+ pass
31
+
32
+
33
+ @dataclass
34
+ class InstallOptions:
35
+ """Tunables for an install run."""
36
+
37
+ scripts_root: Path
38
+ targets: tuple[str, ...] = ()
39
+ force: bool = False
40
+ dry_run: bool = False
41
+ source: str = "local"
42
+ resolved: str = ""
43
+ integrity: str = "" # sha256 of the downloaded artifact (source verify)
44
+
45
+ def __post_init__(self) -> None:
46
+ self.scripts_root = Path(self.scripts_root)
47
+
48
+
49
+ @dataclass
50
+ class InstalledFile:
51
+ rel: str # path of the file relative to the extension's install container
52
+ sha256: str
53
+ size: int
54
+
55
+
56
+ @dataclass
57
+ class InstallResult:
58
+ key: str
59
+ name: str
60
+ version: str
61
+ targets: tuple[str, ...]
62
+ files: list[InstalledFile]
63
+ installed_at: str
64
+ dry_run: bool = False
65
+ container: Path | None = None
66
+
67
+ def describe(self) -> list[str]:
68
+ lines = [
69
+ f"Installed {self.name} {self.version} -> {self.container}"
70
+ if self.container
71
+ else f"Would install {self.name} {self.version}",
72
+ f" targets: {', '.join(self.targets)}",
73
+ ]
74
+ for file in self.files:
75
+ lines.append(f" {file.rel} ({file.size} bytes)")
76
+ return lines
77
+
78
+
79
+ def _glob_regex(pattern: str) -> re.Pattern[str]:
80
+ """Convert a ``**``-aware glob pattern to a full-path regex."""
81
+ if not pattern:
82
+ return re.compile(r"$^")
83
+ if pattern == "**":
84
+ return re.compile(r"^.+$")
85
+ if pattern.endswith("/**"):
86
+ prefix = pattern[:-3]
87
+ inner = _glob_regex(prefix).pattern
88
+ # strip the ^ and $ anchors from inner and append the recursive tail
89
+ core = inner[1:-1] if inner.startswith("^") and inner.endswith("$") else inner
90
+ return re.compile(f"^{core}(?:/.*)?$")
91
+ parts = pattern.split("/")
92
+ rx: list[str] = []
93
+ for part in parts:
94
+ if part == "**":
95
+ rx.append("(?:[^/]+/)*")
96
+ else:
97
+ rx.append(part.replace("*", "[^/]*").replace("?", "[^/]"))
98
+ return re.compile("^" + "".join(rx) + "$")
99
+
100
+
101
+ def _matches_any(patterns: list[str], rel: str) -> bool:
102
+ return any(_glob_regex(p).match(rel) for p in patterns)
103
+
104
+
105
+ def select_files(package_dir: Path, include: list[str] | None = None, exclude: list[str] | None = None) -> list[Path]:
106
+ """Return install files (relative to ``package_dir``) selected by include/exclude."""
107
+ include = include or []
108
+ exclude = exclude or []
109
+ result: list[Path] = []
110
+ for file in sorted(package_dir.rglob("*")):
111
+ if not file.is_file():
112
+ continue
113
+ rel = file.relative_to(package_dir)
114
+ if any(part in ("__pycache__", ".git", ".resolvescript") for part in rel.parts):
115
+ continue
116
+ rel_str = rel.as_posix()
117
+ if include and not _matches_any(include, rel_str):
118
+ continue
119
+ if exclude and _matches_any(exclude, rel_str):
120
+ continue
121
+ result.append(rel)
122
+ return result
123
+
124
+
125
+ def _sha256(path: Path) -> str:
126
+ digest = hashlib.sha256()
127
+ with path.open("rb") as handle:
128
+ for chunk in iter(lambda: handle.read(65536), b""):
129
+ digest.update(chunk)
130
+ return digest.hexdigest()
131
+
132
+
133
+ def discover_entrypoint(package_dir: Path, manifest: Manifest) -> Path | None:
134
+ """Locate the runnable entry file, if any.
135
+
136
+ Priority: ``manifest.entrypoint``, a root ``<name>.py`` script, or the
137
+ consolidated ``dist/<consolidate.output>`` build.
138
+ """
139
+ if manifest.entrypoint:
140
+ candidate = package_dir / manifest.entrypoint
141
+ if not candidate.is_file():
142
+ raise InstallError(f"manifest entrypoint not found: {candidate}")
143
+ return candidate
144
+ direct = package_dir / f"{manifest.name}.py"
145
+ if direct.is_file():
146
+ return direct
147
+ if manifest.consolidate.output:
148
+ candidate = package_dir / "dist" / manifest.consolidate.output
149
+ if candidate.is_file():
150
+ return candidate
151
+ return None
152
+
153
+
154
+ def _compile_entry(staged_entry: Path) -> None:
155
+ """Syntax-check the staged entry file; raises InstallError on failure.
156
+
157
+ The bytecode is written outside the staging tree so it never ships.
158
+ """
159
+ try:
160
+ with tempfile.TemporaryDirectory(prefix="resolvescript-pycheck-") as tmp:
161
+ py_compile.compile(
162
+ str(staged_entry),
163
+ cfile=str(Path(tmp) / "check.pyc"),
164
+ doraise=True,
165
+ )
166
+ except py_compile.PyCompileError as exc:
167
+ raise InstallError(f"entry file is not valid Python: {exc}") from exc
168
+
169
+
170
+ def _occupied_map(registry: dict[str, Any], scripts_root: Path):
171
+ """Map ``(target, relative-path)`` → owning extension name."""
172
+
173
+ occupied: dict[tuple[str, str], str] = {}
174
+ for ext_name, entry in registry.get("extensions", {}).items():
175
+ owner = entry.get("name") or ext_name
176
+ for target in entry.get("targets", []):
177
+ for rel in entry.get("files", []):
178
+ occupied[(target, str(rel))] = owner
179
+ return occupied
180
+
181
+
182
+ def install_package(
183
+ package_dir: Path | str,
184
+ manifest: Manifest,
185
+ options: InstallOptions,
186
+ ) -> InstallResult:
187
+ """Install a materialized package dir (manifest + files) into a Scripts root."""
188
+ from .discovery import target_dir
189
+
190
+ package_dir = Path(package_dir).resolve()
191
+ scripts_root = Path(options.scripts_root).resolve()
192
+ targets = tuple(options.targets) or tuple(manifest.targets) or ("Comp",)
193
+ as_directory = manifest.install.as_directory
194
+
195
+ rel_files = select_files(package_dir, manifest.install.include, manifest.install.exclude)
196
+ entrypoint = discover_entrypoint(package_dir, manifest)
197
+ entry_rel = entrypoint.relative_to(package_dir).as_posix() if entrypoint is not None else None
198
+ if entry_rel is not None and not any(r.as_posix() == entry_rel for r in rel_files):
199
+ rel_files.append(Path(entry_rel))
200
+
201
+ if not as_directory and entry_rel is None:
202
+ raise InstallError(
203
+ f"cannot install {manifest.name} as a single file: no entrypoint "
204
+ "(set manifest 'entrypoint' or provide a <name>.py)"
205
+ )
206
+
207
+ # containers[target] = folder that will hold the installed files
208
+ containers: dict[str, Path] = {}
209
+ for target in targets:
210
+ base = target_dir(scripts_root, target)
211
+ containers[target] = base / manifest.name if as_directory else base
212
+
213
+ # dest layout: for directory installs preserve relative structure; for
214
+ # single-file installs install just the entrypoint under its basename
215
+ layout: dict[str, str] = {r.as_posix(): r.as_posix() for r in rel_files}
216
+ if not as_directory:
217
+ assert entry_rel is not None
218
+ layout = {entry_rel: Path(entry_rel).name}
219
+
220
+ dest_entries: list[tuple[Path, Path, str]] = [] # (source, dest, target)
221
+ for target in targets:
222
+ container = containers[target]
223
+ target_base_rel = target_dir(scripts_root, target)
224
+ for rel, dest_rel in layout.items():
225
+ src = package_dir / rel
226
+ dest = container / dest_rel
227
+ dest_entries.append((src, dest, target))
228
+ assert dest.is_relative_to(target_base_rel), (dest, target_base_rel)
229
+
230
+ registry = read_registry(scripts_root)
231
+ occupied = _occupied_map(registry, scripts_root)
232
+ if not options.force:
233
+ for _src, dest, target in dest_entries:
234
+ rel = dest.relative_to(target_dir(scripts_root, target)).as_posix()
235
+ owner = occupied.get((target, rel))
236
+ if owner is not None and owner != manifest.name:
237
+ raise InstallError(
238
+ f"install would overwrite '{rel}' in '{target}' which is owned by "
239
+ f"'{owner}' (use --force to overwrite)"
240
+ )
241
+
242
+ if options.dry_run:
243
+ seen: set[tuple[str, str]] = set()
244
+ files: list[InstalledFile] = []
245
+ for src, dest, target in dest_entries:
246
+ rel = dest.relative_to(target_dir(scripts_root, target)).as_posix()
247
+ if (target, rel) in seen:
248
+ continue
249
+ seen.add((target, rel))
250
+ files.append(InstalledFile(rel, sha256="", size=src.stat().st_size))
251
+ return InstallResult(
252
+ key=manifest.id or manifest.name,
253
+ name=manifest.name,
254
+ version=manifest.version,
255
+ targets=targets,
256
+ files=files,
257
+ installed_at=installed_at_now(),
258
+ dry_run=True,
259
+ container=containers[targets[0]],
260
+ )
261
+
262
+ installed_files: list[InstalledFile] = []
263
+ for target in targets:
264
+ container = containers[target]
265
+ container.parent.mkdir(parents=True, exist_ok=True)
266
+ stage = Path(
267
+ tempfile.mkdtemp(prefix=".resolvescript-stage-", dir=str(container.parent))
268
+ )
269
+ try:
270
+ for src, dest, entry_target in dest_entries:
271
+ if entry_target != target:
272
+ continue
273
+ staged = stage / dest.relative_to(container)
274
+ staged.parent.mkdir(parents=True, exist_ok=True)
275
+ shutil.copy2(src, staged)
276
+ installed_files.append(
277
+ InstalledFile(
278
+ rel=dest.relative_to(container).as_posix(),
279
+ sha256=_sha256(src),
280
+ size=src.stat().st_size,
281
+ )
282
+ )
283
+ if entry_rel is not None:
284
+ staged_entry = stage / (
285
+ Path(entry_rel).name if not as_directory else Path(entry_rel)
286
+ )
287
+ _compile_entry(staged_entry)
288
+
289
+ if container.exists():
290
+ shutil.rmtree(container)
291
+ stage.replace(container)
292
+ except InstallError:
293
+ shutil.rmtree(stage, ignore_errors=True)
294
+ raise
295
+ except OSError as exc:
296
+ shutil.rmtree(stage, ignore_errors=True)
297
+ raise InstallError(f"failed to install into {container}: {exc}") from exc
298
+
299
+ entry_sha = _sha256(entrypoint) if entrypoint is not None else ""
300
+ result = InstallResult(
301
+ key=manifest.id or manifest.name,
302
+ name=manifest.name,
303
+ version=manifest.version,
304
+ targets=targets,
305
+ files=installed_files,
306
+ installed_at=installed_at_now(),
307
+ container=containers[targets[0]],
308
+ )
309
+ entry = {
310
+ "id": manifest.id or manifest.name,
311
+ "name": manifest.name,
312
+ "version": manifest.version,
313
+ "kind": manifest.kind,
314
+ "as_directory": as_directory,
315
+ "source": options.source
316
+ if options.source != "local"
317
+ else (manifest.release.github_spec or manifest.release.url or "local"),
318
+ "resolved": options.resolved,
319
+ "integrity": options.integrity or entry_sha,
320
+ "files": sorted(
321
+ {f"{manifest.name}/{f.rel}" if as_directory else f.rel for f in installed_files}
322
+ ),
323
+ "targets": list(targets),
324
+ "compat": manifest.compat.as_dict(),
325
+ "installed_at": result.installed_at,
326
+ }
327
+ if manifest.release.owner or manifest.release.repo or manifest.release.url:
328
+ entry["release"] = manifest.release.as_dict()
329
+ add_or_update_entry(scripts_root, result.key, entry)
330
+ return result
331
+
332
+
333
+ def install_project(
334
+ root: Path | str,
335
+ options: InstallOptions,
336
+ manifest: Manifest | None = None,
337
+ ) -> InstallResult:
338
+ """Author flow: install the project in ``root`` (``resolvescript install``)."""
339
+ root = Path(root).resolve()
340
+ if manifest is None:
341
+ from ..manifest.json_reader import load_manifest
342
+
343
+ manifest_path = root / "manifest.json"
344
+ if not manifest_path.is_file():
345
+ from ..manifest.xml_reader import load_manifest as load_xml
346
+
347
+ manifest_path = root / "manifest.xml"
348
+ if not manifest_path.is_file():
349
+ raise ManifestError(
350
+ f"no manifest.json or manifest.xml in {root} (run 'resolvescript create')"
351
+ )
352
+ manifest = load_xml(manifest_path)
353
+ else:
354
+ manifest = load_manifest(manifest_path)
355
+ return install_package(root, manifest, options)
356
+
357
+
358
+ def uninstall_package(name: str, options: InstallOptions) -> list[str]:
359
+ """Remove an extension's installed files via the registry; returns removed paths."""
360
+ from .discovery import target_dir
361
+
362
+ scripts_root = Path(options.scripts_root).resolve()
363
+ registry = read_registry(scripts_root)
364
+ entry = get_extension(registry, name)
365
+ if entry is None:
366
+ raise InstallError(f"'{name}' is not installed in {scripts_root}")
367
+
368
+ removed: list[str] = []
369
+ as_directory = bool(entry.get("as_directory", True))
370
+ key = entry.get("id") or name
371
+ for target in entry.get("targets", []):
372
+ base = target_dir(scripts_root, target)
373
+ if as_directory:
374
+ container = base / (entry.get("name") or key)
375
+ if container.is_dir():
376
+ shutil.rmtree(container)
377
+ removed.append(str(container))
378
+ else:
379
+ for rel in entry.get("files", []):
380
+ path = base / str(rel)
381
+ if path.is_file() or path.is_symlink() and not path.exists():
382
+ path.unlink()
383
+ removed.append(str(path))
384
+ # clean up now-empty directories up to the Scripts root
385
+ current = container.parent if as_directory else base
386
+ while current != scripts_root and current.is_dir() and not any(current.iterdir()):
387
+ current.rmdir()
388
+ current = current.parent
389
+
390
+ remove_entry_from_registry(scripts_root, key)
391
+ return removed or [f"registry entry '{name}'"]
392
+
393
+
394
+ def remove_entry_from_registry(scripts_root: Path, key: str) -> None:
395
+ from .registry import remove_entry
396
+
397
+ remove_entry(scripts_root, key) or remove_entry(scripts_root, key.split(":")[-1])
@@ -0,0 +1,106 @@
1
+ """Install registry: ``Scripts/.resolvescript/install.json``.
2
+
3
+ Records every installed extension so ``manage list``, ``remove`` and conflict
4
+ detection know exactly which files belong to whom. Written atomically.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ REGISTRY_REL = Path(".resolvescript") / "install.json"
18
+ SCHEMA_VERSION = 1
19
+
20
+ EMPTY_REGISTRY: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "extensions": {}}
21
+
22
+
23
+ class RegistryError(Exception):
24
+ pass
25
+
26
+
27
+ def registry_path(scripts_root: Path | str) -> Path:
28
+ return Path(scripts_root) / REGISTRY_REL
29
+
30
+
31
+ def read_registry(scripts_root: Path | str) -> dict[str, Any]:
32
+ """Load the registry; a missing or empty file yields an empty registry."""
33
+ path = registry_path(scripts_root)
34
+ if not path.is_file():
35
+ return copy.deepcopy(EMPTY_REGISTRY)
36
+ try:
37
+ raw: Any = json.loads(path.read_text(encoding="utf-8"))
38
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
39
+ raise RegistryError(f"registry is corrupt at {path}: {exc}") from exc
40
+ if not isinstance(raw, dict):
41
+ raise RegistryError(f"registry at {path} is not a JSON object")
42
+ raw.setdefault("schema_version", SCHEMA_VERSION)
43
+ raw.setdefault("extensions", {})
44
+ if not isinstance(raw["extensions"], dict):
45
+ raise RegistryError(f"registry at {path} has a non-object 'extensions'")
46
+ return raw
47
+
48
+
49
+ def write_registry(scripts_root: Path | str, registry: dict[str, Any]) -> None:
50
+ """Persist the registry atomically (temp file + rename)."""
51
+ root = Path(scripts_root)
52
+ path = registry_path(root)
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ payload = json.dumps(registry, indent=2, sort_keys=True) + "\n"
55
+ fd, tmp = tempfile.mkstemp(prefix=".install-", suffix=".json", dir=path.parent)
56
+ try:
57
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
58
+ handle.write(payload)
59
+ Path(tmp).replace(path)
60
+ except Exception:
61
+ Path(tmp).unlink(missing_ok=True)
62
+ raise
63
+
64
+
65
+ def get_extension(registry: dict[str, Any], key: str) -> dict[str, Any] | None:
66
+ extensions = registry.get("extensions", {})
67
+ for ext_key, entry in extensions.items():
68
+ if ext_key == key or entry.get("id") == key or entry.get("name") == key:
69
+ return entry
70
+ return None
71
+
72
+
73
+ def extension_key(registry: dict[str, Any], name: str, manifest_id: str | None) -> str:
74
+ existing = get_extension(registry, name)
75
+ if existing is not None and manifest_id and existing.get("id") != manifest_id:
76
+ return name
77
+ return manifest_id or name
78
+
79
+
80
+ def add_or_update_entry(
81
+ scripts_root: Path | str,
82
+ key: str,
83
+ entry: dict[str, Any],
84
+ ) -> dict[str, Any]:
85
+ registry = read_registry(scripts_root)
86
+ registry["extensions"][key] = entry
87
+ write_registry(scripts_root, registry)
88
+ return registry
89
+
90
+
91
+ def remove_entry(scripts_root: Path | str, key: str) -> dict[str, Any] | None:
92
+ """Delete an extension from the registry; returns the removed entry."""
93
+ registry = read_registry(scripts_root)
94
+ entry = registry["extensions"].pop(key, None)
95
+ if entry is None:
96
+ for ext_key, candidate in list(registry["extensions"].items()):
97
+ if candidate.get("id") == key or candidate.get("name") == key:
98
+ entry = registry["extensions"].pop(ext_key)
99
+ break
100
+ if entry is not None:
101
+ write_registry(scripts_root, registry)
102
+ return entry
103
+
104
+
105
+ def installed_at_now() -> str:
106
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")