@softspark/ai-toolkit 1.7.0 → 1.9.0
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.
- package/CHANGELOG.md +43 -0
- package/README.md +57 -5
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +6 -0
- package/bin/ai-toolkit.js +37 -0
- package/kb/{planning/enterprise-config-inheritance-plan.md → history/completed/enterprise-config-inheritance-plan-20260412.md} +39 -37
- package/kb/reference/enterprise-config-guide.md +329 -0
- package/llms-full.txt +2518 -2181
- package/llms.txt +2 -1
- package/manifest.json +9 -1
- package/package.json +1 -1
- package/scripts/config_cli.py +537 -0
- package/scripts/config_lock.py +154 -0
- package/scripts/config_merger.py +455 -0
- package/scripts/config_resolver.py +507 -0
- package/scripts/config_scaffold.py +266 -0
- package/scripts/config_validator.py +389 -0
- package/scripts/install.py +163 -1
- package/scripts/install_steps/ai_tools.py +101 -1
- package/scripts/install_steps/install_state.py +24 -0
- package/scripts/install_steps/project_registry.py +142 -0
- package/scripts/projects_cli.py +110 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +163 -0
- package/scripts/update_projects.py +141 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Config resolver for ai-toolkit extends system.
|
|
3
|
+
|
|
4
|
+
Resolves base configs from npm packages, git URLs, or local paths.
|
|
5
|
+
Caches resolved configs in ~/.ai-toolkit/config-cache/.
|
|
6
|
+
|
|
7
|
+
Stdlib-only — no external dependencies.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Constants
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
MAX_EXTENDS_DEPTH = 5
|
|
29
|
+
CONFIG_FILENAME = "ai-toolkit.config.json"
|
|
30
|
+
PROJECT_CONFIG_FILENAME = ".ai-toolkit.json"
|
|
31
|
+
CACHE_DIR_NAME = "config-cache"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cache_root() -> Path:
|
|
35
|
+
"""Return the cache root directory."""
|
|
36
|
+
return Path(os.environ.get("AI_TOOLKIT_HOME", Path.home() / ".ai-toolkit")) / CACHE_DIR_NAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Data classes
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class BaseConfig:
|
|
45
|
+
"""A resolved base configuration."""
|
|
46
|
+
|
|
47
|
+
source: str # original extends string
|
|
48
|
+
root: Path # directory containing the config
|
|
49
|
+
data: dict[str, Any] # parsed ai-toolkit.config.json
|
|
50
|
+
version: str = "" # resolved version (if npm)
|
|
51
|
+
integrity: str = "" # sha256 hash of config file
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
return self.data.get("name", self.source)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def extends(self) -> str | None:
|
|
59
|
+
return self.data.get("extends") or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ResolutionResult:
|
|
64
|
+
"""Result of resolving an extends chain."""
|
|
65
|
+
|
|
66
|
+
configs: list[BaseConfig] = field(default_factory=list)
|
|
67
|
+
warnings: list[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConfigResolverError(Exception):
|
|
71
|
+
"""Raised when config resolution fails."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Public API
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def resolve_extends(
|
|
79
|
+
extends_value: str,
|
|
80
|
+
project_root: str | Path,
|
|
81
|
+
*,
|
|
82
|
+
refresh: bool = False,
|
|
83
|
+
) -> ResolutionResult:
|
|
84
|
+
"""Resolve an extends chain into an ordered list of base configs.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
extends_value: The extends string from .ai-toolkit.json.
|
|
88
|
+
project_root: The project directory (for resolving relative paths).
|
|
89
|
+
refresh: Force re-fetch ignoring cache.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
ResolutionResult with ordered configs (deepest ancestor first).
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ConfigResolverError: On resolution failure.
|
|
96
|
+
"""
|
|
97
|
+
result = ResolutionResult()
|
|
98
|
+
_resolve_chain(extends_value, Path(project_root), set(), result, refresh=refresh)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_project_config(project_root: str | Path) -> dict[str, Any] | None:
|
|
103
|
+
"""Load .ai-toolkit.json from the project root.
|
|
104
|
+
|
|
105
|
+
Returns None if the file doesn't exist.
|
|
106
|
+
"""
|
|
107
|
+
config_path = Path(project_root) / PROJECT_CONFIG_FILENAME
|
|
108
|
+
if not config_path.is_file():
|
|
109
|
+
return None
|
|
110
|
+
return _load_json(config_path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_base_config(config_dir: str | Path) -> dict[str, Any]:
|
|
114
|
+
"""Load ai-toolkit.config.json from a directory."""
|
|
115
|
+
config_path = Path(config_dir) / CONFIG_FILENAME
|
|
116
|
+
if not config_path.is_file():
|
|
117
|
+
raise ConfigResolverError(
|
|
118
|
+
f"Base config not found: {config_path}\n"
|
|
119
|
+
f"Expected {CONFIG_FILENAME} in the extends target directory."
|
|
120
|
+
)
|
|
121
|
+
return _load_json(config_path)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Internal: chain resolution
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _resolve_chain(
|
|
129
|
+
extends_value: str,
|
|
130
|
+
project_root: Path,
|
|
131
|
+
visited: set[str],
|
|
132
|
+
result: ResolutionResult,
|
|
133
|
+
*,
|
|
134
|
+
refresh: bool = False,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Recursively resolve extends chain with cycle + depth detection."""
|
|
137
|
+
# Cycle detection
|
|
138
|
+
canonical = _canonical_source(extends_value)
|
|
139
|
+
if canonical in visited:
|
|
140
|
+
raise ConfigResolverError(
|
|
141
|
+
f"Circular extends detected: '{extends_value}' already in chain "
|
|
142
|
+
f"{' -> '.join(visited)}.\n"
|
|
143
|
+
f"Check your base config's 'extends' field."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Depth check
|
|
147
|
+
if len(visited) >= MAX_EXTENDS_DEPTH:
|
|
148
|
+
raise ConfigResolverError(
|
|
149
|
+
f"Extends chain too deep (max {MAX_EXTENDS_DEPTH} levels).\n"
|
|
150
|
+
f"Chain: {' -> '.join(visited)}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
visited.add(canonical)
|
|
154
|
+
|
|
155
|
+
# Resolve this source
|
|
156
|
+
base_config = _resolve_source(extends_value, project_root, result, refresh=refresh)
|
|
157
|
+
|
|
158
|
+
# Recurse if this base also extends something
|
|
159
|
+
if base_config.extends:
|
|
160
|
+
_resolve_chain(
|
|
161
|
+
base_config.extends,
|
|
162
|
+
base_config.root,
|
|
163
|
+
visited,
|
|
164
|
+
result,
|
|
165
|
+
refresh=refresh,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Append after recursion (deepest ancestor first)
|
|
169
|
+
result.configs.append(base_config)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_source(
|
|
173
|
+
source: str,
|
|
174
|
+
project_root: Path,
|
|
175
|
+
result: ResolutionResult,
|
|
176
|
+
*,
|
|
177
|
+
refresh: bool = False,
|
|
178
|
+
) -> BaseConfig:
|
|
179
|
+
"""Resolve a single extends source."""
|
|
180
|
+
if source.startswith("git+"):
|
|
181
|
+
return _resolve_git(source, result, refresh=refresh)
|
|
182
|
+
if source.startswith(".") or source.startswith("/") or source.startswith("~"):
|
|
183
|
+
return _resolve_local(source, project_root)
|
|
184
|
+
# Default: npm package
|
|
185
|
+
return _resolve_npm(source, result, refresh=refresh)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Resolvers: npm
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _resolve_npm(
|
|
193
|
+
source: str,
|
|
194
|
+
result: ResolutionResult,
|
|
195
|
+
*,
|
|
196
|
+
refresh: bool = False,
|
|
197
|
+
) -> BaseConfig:
|
|
198
|
+
"""Resolve from npm registry via npm pack."""
|
|
199
|
+
package_name, version_spec = _parse_npm_source(source)
|
|
200
|
+
cache_dir = _npm_cache_dir(package_name)
|
|
201
|
+
|
|
202
|
+
# Check cache first (unless refresh)
|
|
203
|
+
if not refresh:
|
|
204
|
+
cached = _find_cached_npm(cache_dir)
|
|
205
|
+
if cached:
|
|
206
|
+
# If we have a cached version and are offline, use it
|
|
207
|
+
try:
|
|
208
|
+
return _load_cached_config(cached, source)
|
|
209
|
+
except ConfigResolverError:
|
|
210
|
+
pass # cache corrupt, re-fetch
|
|
211
|
+
|
|
212
|
+
# Fetch via npm pack
|
|
213
|
+
pack_source = f"{package_name}@{version_spec}" if version_spec else package_name
|
|
214
|
+
|
|
215
|
+
with tempfile.TemporaryDirectory(prefix="ai-toolkit-npm-") as tmp:
|
|
216
|
+
tmp_path = Path(tmp)
|
|
217
|
+
try:
|
|
218
|
+
proc = subprocess.run(
|
|
219
|
+
["npm", "pack", pack_source, "--pack-destination", str(tmp_path)],
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
timeout=60,
|
|
223
|
+
)
|
|
224
|
+
except FileNotFoundError:
|
|
225
|
+
return _offline_fallback(cache_dir, source, result, "npm not found in PATH")
|
|
226
|
+
except subprocess.TimeoutExpired:
|
|
227
|
+
return _offline_fallback(cache_dir, source, result, "npm pack timed out (60s)")
|
|
228
|
+
|
|
229
|
+
if proc.returncode != 0:
|
|
230
|
+
return _offline_fallback(
|
|
231
|
+
cache_dir, source, result,
|
|
232
|
+
f"npm pack failed: {proc.stderr.strip()}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Find the tarball
|
|
236
|
+
tarballs = list(tmp_path.glob("*.tgz"))
|
|
237
|
+
if not tarballs:
|
|
238
|
+
return _offline_fallback(cache_dir, source, result, "npm pack produced no tarball")
|
|
239
|
+
|
|
240
|
+
tarball = tarballs[0]
|
|
241
|
+
version = _extract_version_from_tarball(tarball.name, package_name)
|
|
242
|
+
|
|
243
|
+
# Extract to cache
|
|
244
|
+
dest = cache_dir / version
|
|
245
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
_extract_tarball(tarball, dest)
|
|
247
|
+
|
|
248
|
+
return _load_cached_config(dest, source, version=version)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _parse_npm_source(source: str) -> tuple[str, str]:
|
|
252
|
+
"""Parse 'package@version' into (package, version) tuple."""
|
|
253
|
+
# Handle scoped packages: @scope/pkg@version
|
|
254
|
+
if source.startswith("@"):
|
|
255
|
+
# Find the second @ (version separator)
|
|
256
|
+
rest = source[1:]
|
|
257
|
+
if "@" in rest.split("/", 1)[-1]:
|
|
258
|
+
# @scope/pkg@version
|
|
259
|
+
parts = source.rsplit("@", 1)
|
|
260
|
+
return parts[0], parts[1]
|
|
261
|
+
return source, ""
|
|
262
|
+
|
|
263
|
+
if "@" in source:
|
|
264
|
+
parts = source.rsplit("@", 1)
|
|
265
|
+
return parts[0], parts[1]
|
|
266
|
+
|
|
267
|
+
return source, ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _npm_cache_dir(package_name: str) -> Path:
|
|
271
|
+
"""Cache directory for an npm package."""
|
|
272
|
+
# @scope/pkg -> @scope/pkg/
|
|
273
|
+
return _cache_root() / package_name
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _find_cached_npm(cache_dir: Path) -> Path | None:
|
|
277
|
+
"""Find the latest cached version directory."""
|
|
278
|
+
if not cache_dir.is_dir():
|
|
279
|
+
return None
|
|
280
|
+
versions = sorted(
|
|
281
|
+
[d for d in cache_dir.iterdir() if d.is_dir()],
|
|
282
|
+
key=lambda d: d.name,
|
|
283
|
+
reverse=True,
|
|
284
|
+
)
|
|
285
|
+
return versions[0] if versions else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_tarball(tarball: Path, dest: Path) -> None:
|
|
289
|
+
"""Extract npm tarball (which has a package/ prefix) to dest."""
|
|
290
|
+
with tarfile.open(tarball, "r:gz") as tf:
|
|
291
|
+
for member in tf.getmembers():
|
|
292
|
+
# npm tarballs have a "package/" prefix
|
|
293
|
+
if member.name.startswith("package/"):
|
|
294
|
+
member.name = member.name[len("package/"):]
|
|
295
|
+
if member.name: # skip empty (the "package/" dir itself)
|
|
296
|
+
tf.extract(member, dest)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _extract_version_from_tarball(filename: str, package_name: str) -> str:
|
|
300
|
+
"""Extract version from tarball filename."""
|
|
301
|
+
# Pattern: scope-pkg-1.0.0.tgz or pkg-1.0.0.tgz
|
|
302
|
+
name = filename.removesuffix(".tgz")
|
|
303
|
+
# Remove scope prefix if present
|
|
304
|
+
clean_pkg = package_name.replace("@", "").replace("/", "-")
|
|
305
|
+
if name.startswith(clean_pkg + "-"):
|
|
306
|
+
return name[len(clean_pkg) + 1:]
|
|
307
|
+
return name
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Resolvers: git
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _resolve_git(
|
|
315
|
+
source: str,
|
|
316
|
+
result: ResolutionResult,
|
|
317
|
+
*,
|
|
318
|
+
refresh: bool = False,
|
|
319
|
+
) -> BaseConfig:
|
|
320
|
+
"""Resolve from git URL (git+https://...)."""
|
|
321
|
+
url = source.removeprefix("git+")
|
|
322
|
+
cache_key = hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
323
|
+
cache_dir = _cache_root() / "git" / cache_key
|
|
324
|
+
|
|
325
|
+
if not refresh and cache_dir.is_dir() and (cache_dir / CONFIG_FILENAME).is_file():
|
|
326
|
+
return _load_cached_config(cache_dir, source)
|
|
327
|
+
|
|
328
|
+
# Clone (shallow)
|
|
329
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
try:
|
|
331
|
+
# Clean previous clone if refreshing
|
|
332
|
+
if cache_dir.is_dir():
|
|
333
|
+
shutil.rmtree(cache_dir)
|
|
334
|
+
cache_dir.mkdir(parents=True)
|
|
335
|
+
|
|
336
|
+
proc = subprocess.run(
|
|
337
|
+
["git", "clone", "--depth", "1", url, str(cache_dir)],
|
|
338
|
+
capture_output=True,
|
|
339
|
+
text=True,
|
|
340
|
+
timeout=120,
|
|
341
|
+
)
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
return _offline_fallback(cache_dir, source, result, "git not found in PATH")
|
|
344
|
+
except subprocess.TimeoutExpired:
|
|
345
|
+
return _offline_fallback(cache_dir, source, result, "git clone timed out (120s)")
|
|
346
|
+
|
|
347
|
+
if proc.returncode != 0:
|
|
348
|
+
return _offline_fallback(
|
|
349
|
+
cache_dir, source, result,
|
|
350
|
+
f"git clone failed: {proc.stderr.strip()}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return _load_cached_config(cache_dir, source)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Resolvers: local path
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
def _resolve_local(source: str, project_root: Path) -> BaseConfig:
|
|
361
|
+
"""Resolve from a local path (relative or absolute)."""
|
|
362
|
+
if source.startswith("~"):
|
|
363
|
+
resolved = Path(source).expanduser()
|
|
364
|
+
else:
|
|
365
|
+
resolved = (project_root / source).resolve()
|
|
366
|
+
|
|
367
|
+
if not resolved.is_dir():
|
|
368
|
+
raise ConfigResolverError(
|
|
369
|
+
f"Local extends path not found: {resolved}\n"
|
|
370
|
+
f"Source: '{source}' resolved from project root: {project_root}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return _load_cached_config(resolved, source)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Shared helpers
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
def _load_cached_config(
|
|
381
|
+
config_dir: Path,
|
|
382
|
+
source: str,
|
|
383
|
+
version: str = "",
|
|
384
|
+
) -> BaseConfig:
|
|
385
|
+
"""Load a BaseConfig from a directory."""
|
|
386
|
+
config_path = config_dir / CONFIG_FILENAME
|
|
387
|
+
if not config_path.is_file():
|
|
388
|
+
raise ConfigResolverError(
|
|
389
|
+
f"Base config not found: {config_path}\n"
|
|
390
|
+
f"Expected '{CONFIG_FILENAME}' in extends target.\n"
|
|
391
|
+
f"Source: {source}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
data = _load_json(config_path)
|
|
395
|
+
integrity = _file_hash(config_path)
|
|
396
|
+
|
|
397
|
+
return BaseConfig(
|
|
398
|
+
source=source,
|
|
399
|
+
root=config_dir,
|
|
400
|
+
data=data,
|
|
401
|
+
version=version or data.get("version", ""),
|
|
402
|
+
integrity=integrity,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _offline_fallback(
|
|
407
|
+
cache_dir: Path,
|
|
408
|
+
source: str,
|
|
409
|
+
result: ResolutionResult,
|
|
410
|
+
reason: str,
|
|
411
|
+
) -> BaseConfig:
|
|
412
|
+
"""Fall back to cached config when fetch fails."""
|
|
413
|
+
cached = _find_cached_npm(cache_dir) if cache_dir.is_dir() else None
|
|
414
|
+
|
|
415
|
+
# For git caches, check directly
|
|
416
|
+
if cached is None and cache_dir.is_dir() and (cache_dir / CONFIG_FILENAME).is_file():
|
|
417
|
+
cached = cache_dir
|
|
418
|
+
|
|
419
|
+
if cached:
|
|
420
|
+
result.warnings.append(
|
|
421
|
+
f"Using cached config for '{source}' (offline). Reason: {reason}"
|
|
422
|
+
)
|
|
423
|
+
return _load_cached_config(cached, source)
|
|
424
|
+
|
|
425
|
+
raise ConfigResolverError(
|
|
426
|
+
f"Cannot resolve extends '{source}': {reason}\n"
|
|
427
|
+
f"No cached version found.\n"
|
|
428
|
+
f"Run 'ai-toolkit update --local --refresh-base' when online."
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _load_json(path: Path) -> dict[str, Any]:
|
|
433
|
+
"""Load and parse a JSON file."""
|
|
434
|
+
try:
|
|
435
|
+
with open(path, encoding="utf-8") as f:
|
|
436
|
+
return json.load(f)
|
|
437
|
+
except json.JSONDecodeError as e:
|
|
438
|
+
raise ConfigResolverError(f"Invalid JSON in {path}: {e}") from e
|
|
439
|
+
except OSError as e:
|
|
440
|
+
raise ConfigResolverError(f"Cannot read {path}: {e}") from e
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _file_hash(path: Path) -> str:
|
|
444
|
+
"""SHA-256 hash of a file."""
|
|
445
|
+
h = hashlib.sha256()
|
|
446
|
+
with open(path, "rb") as f:
|
|
447
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
448
|
+
h.update(chunk)
|
|
449
|
+
return f"sha256:{h.hexdigest()}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _canonical_source(source: str) -> str:
|
|
453
|
+
"""Normalize source string for cycle detection."""
|
|
454
|
+
# Strip version specifiers for comparison
|
|
455
|
+
s = source.strip()
|
|
456
|
+
if s.startswith("git+"):
|
|
457
|
+
return s.lower()
|
|
458
|
+
# npm: strip version
|
|
459
|
+
name, _ = _parse_npm_source(s)
|
|
460
|
+
return name.lower()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# CLI entry point (for testing)
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def main() -> None:
|
|
468
|
+
"""CLI: resolve extends and print result as JSON."""
|
|
469
|
+
if len(sys.argv) < 2:
|
|
470
|
+
print("Usage: config_resolver.py <project-dir> [--refresh]", file=sys.stderr)
|
|
471
|
+
sys.exit(1)
|
|
472
|
+
|
|
473
|
+
project_dir = Path(sys.argv[1])
|
|
474
|
+
refresh = "--refresh" in sys.argv
|
|
475
|
+
|
|
476
|
+
config = load_project_config(project_dir)
|
|
477
|
+
if config is None:
|
|
478
|
+
print(json.dumps({"error": f"No {PROJECT_CONFIG_FILENAME} found in {project_dir}"}))
|
|
479
|
+
sys.exit(1)
|
|
480
|
+
|
|
481
|
+
extends = config.get("extends")
|
|
482
|
+
if not extends:
|
|
483
|
+
print(json.dumps({"configs": [], "warnings": []}))
|
|
484
|
+
sys.exit(0)
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
result = resolve_extends(extends, project_dir, refresh=refresh)
|
|
488
|
+
print(json.dumps({
|
|
489
|
+
"configs": [
|
|
490
|
+
{
|
|
491
|
+
"source": c.source,
|
|
492
|
+
"name": c.name,
|
|
493
|
+
"version": c.version,
|
|
494
|
+
"root": str(c.root),
|
|
495
|
+
"integrity": c.integrity,
|
|
496
|
+
}
|
|
497
|
+
for c in result.configs
|
|
498
|
+
],
|
|
499
|
+
"warnings": result.warnings,
|
|
500
|
+
}, indent=2))
|
|
501
|
+
except ConfigResolverError as e:
|
|
502
|
+
print(json.dumps({"error": str(e)}))
|
|
503
|
+
sys.exit(1)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
if __name__ == "__main__":
|
|
507
|
+
main()
|