spawn-cli 0.1.1__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.
- spawn_cli/__init__.py +3 -0
- spawn_cli/cli.py +243 -0
- spawn_cli/core/__init__.py +5 -0
- spawn_cli/core/download.py +532 -0
- spawn_cli/core/errors.py +5 -0
- spawn_cli/core/high_level.py +462 -0
- spawn_cli/core/low_level.py +914 -0
- spawn_cli/core/scripts.py +246 -0
- spawn_cli/errors.py +9 -0
- spawn_cli/ide/__init__.py +63 -0
- spawn_cli/ide/_helpers.py +168 -0
- spawn_cli/ide/_stub.py +63 -0
- spawn_cli/ide/_vacancy.py +221 -0
- spawn_cli/ide/claude_code.py +145 -0
- spawn_cli/ide/codex.py +120 -0
- spawn_cli/ide/cursor.py +122 -0
- spawn_cli/ide/gemini_cli.py +130 -0
- spawn_cli/ide/github_copilot.py +167 -0
- spawn_cli/ide/registry.py +142 -0
- spawn_cli/ide/windsurf.py +91 -0
- spawn_cli/io/__init__.py +21 -0
- spawn_cli/io/json_io.py +21 -0
- spawn_cli/io/lock.py +24 -0
- spawn_cli/io/paths.py +23 -0
- spawn_cli/io/text_io.py +16 -0
- spawn_cli/io/toml_io.py +26 -0
- spawn_cli/io/yaml_io.py +32 -0
- spawn_cli/models/__init__.py +63 -0
- spawn_cli/models/config.py +111 -0
- spawn_cli/models/mcp.py +49 -0
- spawn_cli/models/metadata.py +28 -0
- spawn_cli/models/navigation.py +30 -0
- spawn_cli/models/skill.py +29 -0
- spawn_cli/resources/__init__.py +0 -0
- spawn_cli/resources/default_core_config.yaml +5 -0
- spawn_cli/warnings_display.py +35 -0
- spawn_cli-0.1.1.dist-info/METADATA +11 -0
- spawn_cli-0.1.1.dist-info/RECORD +41 -0
- spawn_cli-0.1.1.dist-info/WHEEL +5 -0
- spawn_cli-0.1.1.dist-info/entry_points.txt +2 -0
- spawn_cli-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import uuid
|
|
8
|
+
import warnings
|
|
9
|
+
import zipfile
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ruamel.yaml import YAML
|
|
20
|
+
|
|
21
|
+
from spawn_cli.core import low_level as ll
|
|
22
|
+
from spawn_cli.core.errors import SpawnError, SpawnWarning
|
|
23
|
+
from spawn_cli.core import scripts
|
|
24
|
+
from spawn_cli.io.json_io import load_json
|
|
25
|
+
from spawn_cli.io.paths import ensure_dir, safe_path
|
|
26
|
+
from spawn_cli.io.yaml_io import load_yaml, save_yaml
|
|
27
|
+
from spawn_cli.models.config import ExtensionConfig, ExtensionsMeta, FileMode, SourceYaml
|
|
28
|
+
|
|
29
|
+
SAFE_YAML = YAML(typ="safe")
|
|
30
|
+
|
|
31
|
+
GIT_INSTALL_HINT = (
|
|
32
|
+
"Install Git for your OS: Windows (winget install Git.Git), "
|
|
33
|
+
"macOS (brew install git or Xcode CLT), "
|
|
34
|
+
"Linux (sudo apt install git or sudo dnf install git)."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compare_version_strings(a: str, b: str) -> int:
|
|
39
|
+
"""Return 1 if a > b, 0 if equal, -1 if a < b.
|
|
40
|
+
|
|
41
|
+
Numeric segments (dot-separated leading part) compared left to right as integers.
|
|
42
|
+
Remaining suffix compared lexicographically after numeric parts are tied.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def split_ver(s: str) -> tuple[list[int], str]:
|
|
46
|
+
s = s.strip()
|
|
47
|
+
m = re.match(r"^((?:\d+\.)*\d+)(.*)$", s)
|
|
48
|
+
if not m:
|
|
49
|
+
return [], s
|
|
50
|
+
num, suf = m.group(1), m.group(2)
|
|
51
|
+
parts = [int(x) for x in num.split(".") if x != ""]
|
|
52
|
+
return parts, suf
|
|
53
|
+
|
|
54
|
+
pa, sa = split_ver(a)
|
|
55
|
+
pb, sb = split_ver(b)
|
|
56
|
+
n = max(len(pa), len(pb))
|
|
57
|
+
for i in range(n):
|
|
58
|
+
va = pa[i] if i < len(pa) else 0
|
|
59
|
+
vb = pb[i] if i < len(pb) else 0
|
|
60
|
+
if va < vb:
|
|
61
|
+
return -1
|
|
62
|
+
if va > vb:
|
|
63
|
+
return 1
|
|
64
|
+
if sa < sb:
|
|
65
|
+
return -1
|
|
66
|
+
if sa > sb:
|
|
67
|
+
return 1
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _require_init(target_root: Path) -> None:
|
|
72
|
+
if not (target_root / "spawn").is_dir():
|
|
73
|
+
raise SpawnError("need init before running this command")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _spawn_extend(target_root: Path, ext: str) -> Path:
|
|
77
|
+
return target_root / "spawn" / ".extend" / ext
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_installed_config(target_root: Path, ext: str) -> ExtensionConfig:
|
|
81
|
+
raw = load_yaml(_spawn_extend(target_root, ext) / "config.yaml")
|
|
82
|
+
if not raw:
|
|
83
|
+
raise SpawnError(f"missing extension config for {ext!r}")
|
|
84
|
+
return ExtensionConfig.model_validate(raw)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_frontmatter(body: str) -> tuple[dict[str, Any], str]:
|
|
88
|
+
lines = body.splitlines(True)
|
|
89
|
+
if not lines or lines[0].strip() != "---":
|
|
90
|
+
return {}, body
|
|
91
|
+
end: int | None = None
|
|
92
|
+
for i in range(1, len(lines)):
|
|
93
|
+
if lines[i].strip() == "---":
|
|
94
|
+
end = i
|
|
95
|
+
break
|
|
96
|
+
if end is None:
|
|
97
|
+
return {}, body
|
|
98
|
+
fm_yaml = "".join(lines[1:end])
|
|
99
|
+
meta = SAFE_YAML.load(fm_yaml)
|
|
100
|
+
meta_dict = meta if isinstance(meta, dict) else {}
|
|
101
|
+
remainder = "".join(lines[end + 1 :])
|
|
102
|
+
return meta_dict, remainder
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _candidate_norm_skill_names(extsrc_dir: Path) -> list[str]:
|
|
106
|
+
raw_cfg = load_yaml(extsrc_dir / "config.yaml")
|
|
107
|
+
if not raw_cfg:
|
|
108
|
+
return []
|
|
109
|
+
cfg = ExtensionConfig.model_validate(raw_cfg)
|
|
110
|
+
skills_dir = extsrc_dir / "skills"
|
|
111
|
+
if not skills_dir.is_dir():
|
|
112
|
+
return []
|
|
113
|
+
norms: list[str] = []
|
|
114
|
+
for skill_path in sorted(p for p in skills_dir.glob("*.md") if p.is_file()):
|
|
115
|
+
key = skill_path.name
|
|
116
|
+
sk = cfg.skills.get(key)
|
|
117
|
+
text = skill_path.read_text(encoding="utf-8")
|
|
118
|
+
fm, _ = _parse_frontmatter(text)
|
|
119
|
+
fm_name = fm.get("name")
|
|
120
|
+
if sk and sk.name:
|
|
121
|
+
name = sk.name
|
|
122
|
+
elif fm_name is not None:
|
|
123
|
+
name = str(fm_name)
|
|
124
|
+
else:
|
|
125
|
+
name = key.replace(".md", "")
|
|
126
|
+
norms.append(ll.normalize_skill_name(name))
|
|
127
|
+
return norms
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _candidate_mcp_server_names(extsrc_dir: Path) -> list[str]:
|
|
131
|
+
path = extsrc_dir / "mcp.json"
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
return []
|
|
134
|
+
data = load_json(path)
|
|
135
|
+
names: list[str] = []
|
|
136
|
+
for s in data.get("servers") or []:
|
|
137
|
+
if isinstance(s, dict) and s.get("name"):
|
|
138
|
+
names.append(str(s["name"]))
|
|
139
|
+
return names
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _validate_render_identity_for_new_extension(target_root: Path, new_name: str, extsrc_dir: Path) -> None:
|
|
143
|
+
skill_claims: dict[str, list[str]] = defaultdict(list)
|
|
144
|
+
mcp_claims: dict[str, list[str]] = defaultdict(list)
|
|
145
|
+
for ext in ll.list_extensions(target_root):
|
|
146
|
+
if ext == new_name:
|
|
147
|
+
continue
|
|
148
|
+
for meta in ll.generate_skills_metadata(target_root, ext):
|
|
149
|
+
skill_claims[ll.normalize_skill_name(meta.name)].append(ext)
|
|
150
|
+
for srv in ll.list_mcp(target_root, ext).servers:
|
|
151
|
+
mcp_claims[srv.name].append(ext)
|
|
152
|
+
for nk in _candidate_norm_skill_names(extsrc_dir):
|
|
153
|
+
skill_claims[nk].append(new_name)
|
|
154
|
+
for nm in _candidate_mcp_server_names(extsrc_dir):
|
|
155
|
+
mcp_claims[nm].append(new_name)
|
|
156
|
+
for key, holders in skill_claims.items():
|
|
157
|
+
if len(set(holders)) > 1:
|
|
158
|
+
raise SpawnError(
|
|
159
|
+
f"duplicate normalized skill names across extensions ({key!r}): extensions {sorted(set(holders))}"
|
|
160
|
+
)
|
|
161
|
+
for srv_name, holders in mcp_claims.items():
|
|
162
|
+
if len(set(holders)) > 1:
|
|
163
|
+
raise SpawnError(
|
|
164
|
+
f"duplicate MCP server names across extensions ({srv_name!r}): extensions {sorted(set(holders))}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _check_path_conflicts(target_root: Path, candidate_config: ExtensionConfig, candidate_ext: str) -> None:
|
|
169
|
+
for installed_ext in ll.list_extensions(target_root):
|
|
170
|
+
if installed_ext == candidate_ext:
|
|
171
|
+
continue
|
|
172
|
+
installed_config = _load_installed_config(target_root, installed_ext)
|
|
173
|
+
for file_path in candidate_config.files:
|
|
174
|
+
if file_path in installed_config.files:
|
|
175
|
+
raise SpawnError(f"File conflict: {file_path} claimed by {installed_ext}")
|
|
176
|
+
for folder in candidate_config.folders:
|
|
177
|
+
if folder in installed_config.folders:
|
|
178
|
+
raise SpawnError(f"Folder conflict: {folder} claimed by {installed_ext}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _source_info_key(info: SourceYaml.SourceInfo) -> tuple[str, str, str | None]:
|
|
182
|
+
return (info.type, info.path, info.branch)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _load_stored_source(target_root: Path, ext_name: str) -> SourceYaml | None:
|
|
186
|
+
p = _spawn_extend(target_root, ext_name) / "source.yaml"
|
|
187
|
+
if not p.is_file():
|
|
188
|
+
return None
|
|
189
|
+
raw = load_yaml(p)
|
|
190
|
+
if not raw:
|
|
191
|
+
return None
|
|
192
|
+
return SourceYaml.model_validate(raw)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _check_install_version_and_source(
|
|
196
|
+
target_root: Path,
|
|
197
|
+
ext_name: str,
|
|
198
|
+
candidate_cfg: ExtensionConfig,
|
|
199
|
+
candidate_source: SourceYaml.SourceInfo,
|
|
200
|
+
) -> None:
|
|
201
|
+
stored = _load_stored_source(target_root, ext_name)
|
|
202
|
+
if not stored:
|
|
203
|
+
return
|
|
204
|
+
if _source_info_key(candidate_source) != _source_info_key(stored.source):
|
|
205
|
+
raise SpawnError(
|
|
206
|
+
"source identity does not match installed record; remove the extension then install with the new source"
|
|
207
|
+
)
|
|
208
|
+
if compare_version_strings(candidate_cfg.version, stored.installed.version) <= 0:
|
|
209
|
+
raise SpawnError(
|
|
210
|
+
f"extension version must be newer than installed {stored.installed.version!r} (got {candidate_cfg.version!r})"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _write_source_yaml(
|
|
215
|
+
target_root: Path,
|
|
216
|
+
ext_name: str,
|
|
217
|
+
source: SourceYaml.SourceInfo,
|
|
218
|
+
installed_version: str,
|
|
219
|
+
) -> None:
|
|
220
|
+
path = _spawn_extend(target_root, ext_name) / "source.yaml"
|
|
221
|
+
rec = SourceYaml(
|
|
222
|
+
extension=ext_name,
|
|
223
|
+
source=source,
|
|
224
|
+
installed=SourceYaml.InstalledInfo(
|
|
225
|
+
version=installed_version,
|
|
226
|
+
installedAt=datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
save_yaml(path, rec.model_dump(by_alias=True, exclude_none=True))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _find_extsrc(local_root: Path) -> Path:
|
|
233
|
+
cand = local_root / "extsrc"
|
|
234
|
+
if (cand / "config.yaml").is_file():
|
|
235
|
+
return cand
|
|
236
|
+
if local_root.name == "extsrc" and (local_root / "config.yaml").is_file():
|
|
237
|
+
return local_root
|
|
238
|
+
raise SpawnError(f"no extsrc/config.yaml under {local_root}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _ensure_git() -> None:
|
|
242
|
+
try:
|
|
243
|
+
subprocess.run(["git", "--version"], capture_output=True, check=True)
|
|
244
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
245
|
+
raise SpawnError(f"git is required for remote extension sources. {GIT_INSTALL_HINT}") from e
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _git_clone(url: str, branch: str | None, dest: Path) -> None:
|
|
249
|
+
_ensure_git()
|
|
250
|
+
cmd = ["git", "clone", "--depth", "1"]
|
|
251
|
+
if branch:
|
|
252
|
+
cmd += ["--branch", branch]
|
|
253
|
+
cmd += [url, str(dest)]
|
|
254
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
255
|
+
if proc.returncode != 0:
|
|
256
|
+
raise SpawnError(f"git clone failed: {(proc.stderr or proc.stdout).strip()}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _extract_zip(zpath: Path, dest: Path) -> None:
|
|
260
|
+
dest_resolved = dest.resolve()
|
|
261
|
+
with zipfile.ZipFile(zpath, "r") as zf:
|
|
262
|
+
for member in zf.infolist():
|
|
263
|
+
if member.filename.startswith(("/", "\\")) or ".." in Path(member.filename).parts:
|
|
264
|
+
raise SpawnError("zip entry escapes staging directory (path traversal)")
|
|
265
|
+
out = (dest / member.filename).resolve()
|
|
266
|
+
try:
|
|
267
|
+
out.relative_to(dest_resolved)
|
|
268
|
+
except ValueError:
|
|
269
|
+
raise SpawnError("zip entry escapes staging directory (path traversal)") from None
|
|
270
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
if member.is_dir():
|
|
272
|
+
continue
|
|
273
|
+
with zf.open(member, "r") as src, open(out, "wb") as dst:
|
|
274
|
+
shutil.copyfileobj(src, dst)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _download_zip(url: str, dest_zip: Path) -> None:
|
|
278
|
+
with httpx.Client(follow_redirects=True, timeout=120.0) as client:
|
|
279
|
+
r = client.get(url)
|
|
280
|
+
r.raise_for_status()
|
|
281
|
+
dest_zip.write_bytes(r.content)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _resolve_local_spec(path_str: str) -> Path:
|
|
285
|
+
p = Path(path_str).expanduser()
|
|
286
|
+
if not p.exists():
|
|
287
|
+
raise SpawnError(f"local path does not exist: {path_str!r}")
|
|
288
|
+
return p.resolve()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def stage_repository_root(path: str, branch: str | None, temp_base: Path) -> Path:
|
|
292
|
+
"""Return the root of a cloned or extracted repository (for build manifests or target repos)."""
|
|
293
|
+
p = urlparse(path)
|
|
294
|
+
if p.scheme in ("http", "https"):
|
|
295
|
+
if path.lower().endswith(".zip"):
|
|
296
|
+
ensure_dir(temp_base)
|
|
297
|
+
zf = temp_base / "repo.zip"
|
|
298
|
+
_download_zip(path, zf)
|
|
299
|
+
out = temp_base / "zip_out"
|
|
300
|
+
ensure_dir(out)
|
|
301
|
+
_extract_zip(zf, out)
|
|
302
|
+
subs = sorted(d for d in out.iterdir() if d.is_dir())
|
|
303
|
+
return subs[0] if len(subs) == 1 else out
|
|
304
|
+
_ensure_git()
|
|
305
|
+
cl = temp_base / "git_out"
|
|
306
|
+
_git_clone(path, branch, cl)
|
|
307
|
+
subs = sorted(d for d in cl.iterdir() if d.is_dir())
|
|
308
|
+
if len(subs) == 1 and not (cl / "spawn").is_dir():
|
|
309
|
+
return subs[0]
|
|
310
|
+
return cl
|
|
311
|
+
local = _resolve_local_spec(path)
|
|
312
|
+
if local.is_file() and local.suffix.lower() == ".zip":
|
|
313
|
+
ensure_dir(temp_base)
|
|
314
|
+
out = temp_base / "local_zip"
|
|
315
|
+
ensure_dir(out)
|
|
316
|
+
_extract_zip(local, out)
|
|
317
|
+
subs = sorted(d for d in out.iterdir() if d.is_dir())
|
|
318
|
+
return subs[0] if len(subs) == 1 else out
|
|
319
|
+
return local
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _find_build_manifest(root: Path) -> Path:
|
|
323
|
+
mf = root / "extensions.yaml"
|
|
324
|
+
if mf.is_file():
|
|
325
|
+
return mf
|
|
326
|
+
subs = sorted(d for d in root.iterdir() if d.is_dir())
|
|
327
|
+
if len(subs) == 1:
|
|
328
|
+
nested = subs[0] / "extensions.yaml"
|
|
329
|
+
if nested.is_file():
|
|
330
|
+
return nested
|
|
331
|
+
raise SpawnError("extensions.yaml not found in build source")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _materialize_files(target_root: Path, extension: str, config: ExtensionConfig) -> None:
|
|
335
|
+
ext_root = _spawn_extend(target_root, extension)
|
|
336
|
+
files_src = ext_root / "files"
|
|
337
|
+
for rel_path, ent in config.files.items():
|
|
338
|
+
dest = safe_path(target_root, rel_path.replace("\\", "/"))
|
|
339
|
+
src = files_src.joinpath(*Path(rel_path).parts)
|
|
340
|
+
if ent.mode == FileMode.static:
|
|
341
|
+
if not src.is_file():
|
|
342
|
+
continue
|
|
343
|
+
if dest.exists() and dest.is_file():
|
|
344
|
+
warnings.warn(
|
|
345
|
+
f"Replacing existing file from extension (static): {rel_path}",
|
|
346
|
+
SpawnWarning,
|
|
347
|
+
)
|
|
348
|
+
ensure_dir(dest.parent)
|
|
349
|
+
shutil.copy2(src, dest)
|
|
350
|
+
else:
|
|
351
|
+
if not src.is_file():
|
|
352
|
+
continue
|
|
353
|
+
if not dest.exists():
|
|
354
|
+
ensure_dir(dest.parent)
|
|
355
|
+
shutil.copy2(src, dest)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _copy_extsrc_tree(extsrc_dir: Path, dest: Path) -> None:
|
|
359
|
+
if dest.exists():
|
|
360
|
+
shutil.rmtree(dest)
|
|
361
|
+
shutil.copytree(extsrc_dir, dest, dirs_exist_ok=False)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _stage_from_path_string(
|
|
365
|
+
target_root: Path,
|
|
366
|
+
path: str,
|
|
367
|
+
branch: str | None,
|
|
368
|
+
temp_base: Path,
|
|
369
|
+
) -> tuple[Path, SourceYaml.SourceInfo]:
|
|
370
|
+
del target_root
|
|
371
|
+
p = urlparse(path)
|
|
372
|
+
if p.scheme in ("http", "https"):
|
|
373
|
+
if path.lower().endswith(".zip"):
|
|
374
|
+
ensure_dir(temp_base)
|
|
375
|
+
zf = temp_base / "src.zip"
|
|
376
|
+
_download_zip(path, zf)
|
|
377
|
+
extract_to = temp_base / "zip_out"
|
|
378
|
+
ensure_dir(extract_to)
|
|
379
|
+
_extract_zip(zf, extract_to)
|
|
380
|
+
extsrc_root = extract_to
|
|
381
|
+
for child in sorted(extract_to.iterdir(), key=lambda x: x.name):
|
|
382
|
+
if child.is_dir() and (child / "extsrc").is_dir():
|
|
383
|
+
extsrc_root = child
|
|
384
|
+
break
|
|
385
|
+
extdir = _find_extsrc(extsrc_root if (extsrc_root / "extsrc").is_dir() else extsrc_root)
|
|
386
|
+
return extdir, SourceYaml.SourceInfo(type="zip", path=path, branch=None, resolved=None)
|
|
387
|
+
_ensure_git()
|
|
388
|
+
clone_dir = temp_base / "git"
|
|
389
|
+
_git_clone(path, branch, clone_dir)
|
|
390
|
+
root = clone_dir
|
|
391
|
+
if (root / "extsrc" / "config.yaml").is_file():
|
|
392
|
+
pass
|
|
393
|
+
else:
|
|
394
|
+
subdirs = [d for d in root.iterdir() if d.is_dir()]
|
|
395
|
+
if subdirs and (subdirs[0] / "extsrc" / "config.yaml").is_file():
|
|
396
|
+
root = subdirs[0]
|
|
397
|
+
extdir = _find_extsrc(root)
|
|
398
|
+
return extdir, SourceYaml.SourceInfo(type="git", path=path, branch=branch, resolved=None)
|
|
399
|
+
local = _resolve_local_spec(path)
|
|
400
|
+
if local.is_file() and local.suffix.lower() == ".zip":
|
|
401
|
+
ensure_dir(temp_base)
|
|
402
|
+
extract_to = temp_base / "local_zip"
|
|
403
|
+
ensure_dir(extract_to)
|
|
404
|
+
_extract_zip(local, extract_to)
|
|
405
|
+
extdir = _find_extsrc(extract_to)
|
|
406
|
+
return extdir, SourceYaml.SourceInfo(type="zip", path=str(local), branch=None, resolved=None)
|
|
407
|
+
extdir = _find_extsrc(local)
|
|
408
|
+
return extdir, SourceYaml.SourceInfo(type="local", path=str(local), branch=branch, resolved=None)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@dataclass
|
|
412
|
+
class StagedExtension:
|
|
413
|
+
extsrc_dir: Path
|
|
414
|
+
source_info: SourceYaml.SourceInfo
|
|
415
|
+
config: ExtensionConfig
|
|
416
|
+
temp_base: Path
|
|
417
|
+
|
|
418
|
+
def cleanup(self) -> None:
|
|
419
|
+
if self.temp_base.exists():
|
|
420
|
+
shutil.rmtree(self.temp_base, ignore_errors=True)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _stage_extension(
|
|
424
|
+
target_root: Path | None,
|
|
425
|
+
path: str,
|
|
426
|
+
branch: str | None,
|
|
427
|
+
*,
|
|
428
|
+
require_init: bool,
|
|
429
|
+
) -> StagedExtension:
|
|
430
|
+
if require_init:
|
|
431
|
+
if target_root is None:
|
|
432
|
+
raise SpawnError("target_root is required when require_init is True")
|
|
433
|
+
_require_init(target_root)
|
|
434
|
+
op = str(uuid.uuid4())
|
|
435
|
+
if target_root is not None:
|
|
436
|
+
parent = target_root / "spawn" / ".metadata" / "temp"
|
|
437
|
+
else:
|
|
438
|
+
parent = Path(tempfile.gettempdir()) / "spawn-stage"
|
|
439
|
+
temp_base = parent / op
|
|
440
|
+
ensure_dir(temp_base.parent)
|
|
441
|
+
ll.prune_metadata_temp(
|
|
442
|
+
temp_base.parent,
|
|
443
|
+
max_age_seconds=ll.METADATA_TEMP_MAX_AGE_SECONDS,
|
|
444
|
+
reserved=op,
|
|
445
|
+
)
|
|
446
|
+
try:
|
|
447
|
+
anchor = target_root if target_root is not None else Path.cwd()
|
|
448
|
+
extsrc_dir, source_info = _stage_from_path_string(anchor, path, branch, temp_base)
|
|
449
|
+
raw = load_yaml(extsrc_dir / "config.yaml")
|
|
450
|
+
if not raw:
|
|
451
|
+
raise SpawnError("extsrc/config.yaml missing or empty")
|
|
452
|
+
cfg = ExtensionConfig.model_validate(raw)
|
|
453
|
+
return StagedExtension(extsrc_dir=extsrc_dir, source_info=source_info, config=cfg, temp_base=temp_base)
|
|
454
|
+
except BaseException:
|
|
455
|
+
if temp_base.exists():
|
|
456
|
+
shutil.rmtree(temp_base, ignore_errors=True)
|
|
457
|
+
raise
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def download_extension(target_root: Path, path: str, branch: str | None = None) -> str:
|
|
461
|
+
"""Stage, validate, copy extension into spawn/.extend/{name}, write source.yaml. Returns extension name."""
|
|
462
|
+
staged = _stage_extension(target_root, path, branch, require_init=True)
|
|
463
|
+
try:
|
|
464
|
+
candidate_cfg = staged.config
|
|
465
|
+
ext_name = candidate_cfg.name or "extension"
|
|
466
|
+
_check_install_version_and_source(target_root, ext_name, candidate_cfg, staged.source_info)
|
|
467
|
+
_check_path_conflicts(target_root, candidate_cfg, ext_name)
|
|
468
|
+
_validate_render_identity_for_new_extension(target_root, ext_name, staged.extsrc_dir)
|
|
469
|
+
scripts.run_before_install_scripts(target_root, ext_name, ext_layout=staged.extsrc_dir)
|
|
470
|
+
dest = _spawn_extend(target_root, ext_name)
|
|
471
|
+
ensure_dir(dest.parent)
|
|
472
|
+
_copy_extsrc_tree(staged.extsrc_dir, dest)
|
|
473
|
+
_write_source_yaml(target_root, ext_name, staged.source_info, candidate_cfg.version)
|
|
474
|
+
_materialize_files(target_root, ext_name, candidate_cfg)
|
|
475
|
+
return ext_name
|
|
476
|
+
finally:
|
|
477
|
+
staged.cleanup()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def install_extension(target_root: Path, path: str, branch: str | None = None) -> None:
|
|
481
|
+
from spawn_cli.core import high_level as hl
|
|
482
|
+
|
|
483
|
+
hl.install_extension(target_root, path, branch)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def install_build(target_root: Path, path: str, branch: str | None = None) -> None:
|
|
487
|
+
for ent in list_build_extensions(path, branch):
|
|
488
|
+
p_ent = ent.get("path")
|
|
489
|
+
if not p_ent:
|
|
490
|
+
continue
|
|
491
|
+
br = ent.get("branch")
|
|
492
|
+
install_extension(target_root, str(p_ent), br if br is not None else branch)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def list_build_extensions(path: str, branch: str | None = None) -> list[dict]:
|
|
496
|
+
root = Path(path).expanduser().resolve()
|
|
497
|
+
if root.is_dir() and (root / "extensions.yaml").is_file():
|
|
498
|
+
raw = load_yaml(root / "extensions.yaml")
|
|
499
|
+
meta = ExtensionsMeta.model_validate(raw)
|
|
500
|
+
return [e.model_dump() for e in meta.extensions]
|
|
501
|
+
|
|
502
|
+
td = Path(tempfile.mkdtemp(prefix="spawn-build-list-"))
|
|
503
|
+
try:
|
|
504
|
+
work = td / "stage"
|
|
505
|
+
repo_root = stage_repository_root(str(path), branch, work)
|
|
506
|
+
mf = _find_build_manifest(repo_root)
|
|
507
|
+
raw = load_yaml(mf)
|
|
508
|
+
meta = ExtensionsMeta.model_validate(raw)
|
|
509
|
+
return [e.model_dump() for e in meta.extensions]
|
|
510
|
+
finally:
|
|
511
|
+
shutil.rmtree(td, ignore_errors=True)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
__all__ = [
|
|
515
|
+
"StagedExtension",
|
|
516
|
+
"compare_version_strings",
|
|
517
|
+
"download_extension",
|
|
518
|
+
"install_build",
|
|
519
|
+
"install_extension",
|
|
520
|
+
"list_build_extensions",
|
|
521
|
+
"stage_repository_root",
|
|
522
|
+
"_check_install_version_and_source",
|
|
523
|
+
"_copy_extsrc_tree",
|
|
524
|
+
"_find_build_manifest",
|
|
525
|
+
"_load_stored_source",
|
|
526
|
+
"_materialize_files",
|
|
527
|
+
"_spawn_extend",
|
|
528
|
+
"_source_info_key",
|
|
529
|
+
"_stage_extension",
|
|
530
|
+
"_validate_render_identity_for_new_extension",
|
|
531
|
+
"_write_source_yaml",
|
|
532
|
+
]
|