steersync 0.2.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.
- steersync/__init__.py +5 -0
- steersync/__main__.py +6 -0
- steersync/cli.py +768 -0
- steersync/config.py +532 -0
- steersync/errors.py +52 -0
- steersync/gitops.py +550 -0
- steersync/hooks.py +146 -0
- steersync/lint.py +631 -0
- steersync/locking.py +69 -0
- steersync/logging_utils.py +60 -0
- steersync/mcp_server.py +171 -0
- steersync/paths.py +98 -0
- steersync/resources/default_config.yaml +154 -0
- steersync/steering.py +457 -0
- steersync/sync.py +726 -0
- steersync-0.2.0.dist-info/METADATA +342 -0
- steersync-0.2.0.dist-info/RECORD +20 -0
- steersync-0.2.0.dist-info/WHEEL +4 -0
- steersync-0.2.0.dist-info/entry_points.txt +2 -0
- steersync-0.2.0.dist-info/licenses/LICENSE +190 -0
steersync/config.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""Configuration loading and validation.
|
|
2
|
+
|
|
3
|
+
Configuration is layered: packaged defaults, then an optional YAML file, then
|
|
4
|
+
environment variables. Everything is validated with pydantic before use.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
20
|
+
|
|
21
|
+
from steersync.errors import ConfigError
|
|
22
|
+
|
|
23
|
+
ENV_CONFIG = "STEERSYNC_CONFIG"
|
|
24
|
+
ENV_REMOTE = "STEERSYNC_REMOTE"
|
|
25
|
+
ENV_DEFAULT_BRANCH = "STEERSYNC_DEFAULT_BRANCH"
|
|
26
|
+
ENV_CACHE_DIR = "STEERSYNC_CACHE_DIR"
|
|
27
|
+
ENV_LOG_LEVEL = "STEERSYNC_LOG_LEVEL"
|
|
28
|
+
ENV_LOG_FORMAT = "STEERSYNC_LOG_FORMAT"
|
|
29
|
+
ENV_STRICT = "STEERSYNC_STRICT"
|
|
30
|
+
ENV_GIT_BINARY = "STEERSYNC_GIT_BINARY"
|
|
31
|
+
|
|
32
|
+
CONFIG_FILE_NAMES: tuple[str, ...] = ("steersync.yaml", "steersync.yml")
|
|
33
|
+
|
|
34
|
+
_SCP_LIKE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.\-\[\]]+:(?![/\\]{2})[^\s]+$")
|
|
35
|
+
_HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+$")
|
|
36
|
+
_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}")
|
|
37
|
+
_ALLOWED_PLACEHOLDERS = frozenset({"default_branch", "project", "user", "hash"})
|
|
38
|
+
_COLLECTION_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
|
39
|
+
|
|
40
|
+
LintProfile = Literal["steering", "markdown", "skill", "agent", "hooks", "json", "generic"]
|
|
41
|
+
LINT_PROFILES: tuple[str, ...] = (
|
|
42
|
+
"steering",
|
|
43
|
+
"markdown",
|
|
44
|
+
"skill",
|
|
45
|
+
"agent",
|
|
46
|
+
"hooks",
|
|
47
|
+
"json",
|
|
48
|
+
"generic",
|
|
49
|
+
)
|
|
50
|
+
# Deprecated ``paths`` keys and the collection setting that replaced each one.
|
|
51
|
+
_LEGACY_PATH_KEYS: dict[str, tuple[str, str]] = {
|
|
52
|
+
"workspace_steering": ("steering", "workspace_path"),
|
|
53
|
+
"global_steering": ("steering", "global_path"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Strict(BaseModel):
|
|
60
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ForbiddenPattern(_Strict):
|
|
64
|
+
"""A regular expression that must not appear in a steering document."""
|
|
65
|
+
|
|
66
|
+
id: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
|
67
|
+
pattern: str = Field(min_length=1, max_length=512)
|
|
68
|
+
message: str = Field(min_length=1, max_length=200)
|
|
69
|
+
|
|
70
|
+
@field_validator("pattern")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _compiles(cls, value: str) -> str:
|
|
73
|
+
try:
|
|
74
|
+
re.compile(value)
|
|
75
|
+
except re.error as exc:
|
|
76
|
+
msg = f"invalid regular expression: {exc}"
|
|
77
|
+
raise ValueError(msg) from exc
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PyMarkdownConfig(_Strict):
|
|
82
|
+
enabled: bool = True
|
|
83
|
+
severity: Literal["error", "warning"] = "warning"
|
|
84
|
+
disabled_rules: tuple[str, ...] = ()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class LintConfig(_Strict):
|
|
88
|
+
max_file_bytes: int = Field(default=65536, ge=1024, le=10_000_000)
|
|
89
|
+
require_h1: bool = True
|
|
90
|
+
allowed_extra_keys: tuple[str, ...] = ("x-steersync",)
|
|
91
|
+
file_reference_check: bool = True
|
|
92
|
+
pymarkdown: PyMarkdownConfig = PyMarkdownConfig()
|
|
93
|
+
forbidden_patterns: tuple[ForbiddenPattern, ...] = ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RemoteConfig(_Strict):
|
|
97
|
+
url: str = ""
|
|
98
|
+
default_branch: str = Field(default="main", min_length=1, max_length=200)
|
|
99
|
+
global_dir: str = Field(default="global", min_length=1)
|
|
100
|
+
projects_dir: str = Field(default="projects", min_length=1)
|
|
101
|
+
allowed_hosts: tuple[str, ...] = ()
|
|
102
|
+
allow_local: bool = False
|
|
103
|
+
push_options: tuple[str, ...] = ()
|
|
104
|
+
|
|
105
|
+
@field_validator("global_dir", "projects_dir")
|
|
106
|
+
@classmethod
|
|
107
|
+
def _relative_dir(cls, value: str) -> str:
|
|
108
|
+
parts = Path(value).parts
|
|
109
|
+
if Path(value).is_absolute() or ".." in parts or not parts:
|
|
110
|
+
msg = "must be a relative directory inside the repository"
|
|
111
|
+
raise ValueError(msg)
|
|
112
|
+
return value.strip("/")
|
|
113
|
+
|
|
114
|
+
@field_validator("push_options")
|
|
115
|
+
@classmethod
|
|
116
|
+
def _push_options(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
117
|
+
for option in value:
|
|
118
|
+
if "\n" in option or "\r" in option or not option.strip():
|
|
119
|
+
msg = "push options must be single non empty lines"
|
|
120
|
+
raise ValueError(msg)
|
|
121
|
+
unknown = set(_PLACEHOLDER_RE.findall(option)) - _ALLOWED_PLACEHOLDERS
|
|
122
|
+
if unknown:
|
|
123
|
+
msg = f"unknown placeholder(s) in push option: {sorted(unknown)}"
|
|
124
|
+
raise ValueError(msg)
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
@field_validator("allowed_hosts")
|
|
128
|
+
@classmethod
|
|
129
|
+
def _hosts(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
130
|
+
for host in value:
|
|
131
|
+
if not _HOST_RE.match(host):
|
|
132
|
+
msg = f"invalid host name: {host!r}"
|
|
133
|
+
raise ValueError(msg)
|
|
134
|
+
return tuple(h.lower() for h in value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class GitConfig(_Strict):
|
|
138
|
+
binary: str = Field(default="git", min_length=1)
|
|
139
|
+
timeout_seconds: int = Field(default=120, ge=5, le=3600)
|
|
140
|
+
clone_depth: int = Field(default=1, ge=1, le=1000)
|
|
141
|
+
retries: int = Field(default=2, ge=0, le=10)
|
|
142
|
+
retry_backoff_seconds: float = Field(default=2.0, ge=0, le=60)
|
|
143
|
+
retry_max_backoff_seconds: float = Field(default=30.0, ge=0, le=600)
|
|
144
|
+
|
|
145
|
+
@field_validator("binary")
|
|
146
|
+
@classmethod
|
|
147
|
+
def _binary(cls, value: str) -> str:
|
|
148
|
+
if value.startswith("-") or any(c in value for c in "\n\r\0"):
|
|
149
|
+
msg = "invalid git binary"
|
|
150
|
+
raise ValueError(msg)
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class BranchConfig(_Strict):
|
|
155
|
+
template: str = Field(default="steering/{project}/{user}-{hash}", min_length=1)
|
|
156
|
+
hash_length: int = Field(default=8, ge=6, le=64)
|
|
157
|
+
|
|
158
|
+
@field_validator("template")
|
|
159
|
+
@classmethod
|
|
160
|
+
def _template(cls, value: str) -> str:
|
|
161
|
+
names = set(_PLACEHOLDER_RE.findall(value))
|
|
162
|
+
unknown = names - {"project", "user", "hash"}
|
|
163
|
+
if unknown:
|
|
164
|
+
msg = f"unknown placeholder(s) in branch template: {sorted(unknown)}"
|
|
165
|
+
raise ValueError(msg)
|
|
166
|
+
if "{hash}" not in value:
|
|
167
|
+
msg = "branch template must contain {hash} so identical pushes dedupe"
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _relative_path(value: str) -> str:
|
|
173
|
+
"""Validate a path that must stay inside its base directory."""
|
|
174
|
+
if Path(value).is_absolute() or ".." in Path(value).parts or not value.strip():
|
|
175
|
+
msg = "must be a relative path without parent references"
|
|
176
|
+
raise ValueError(msg)
|
|
177
|
+
return value.strip().strip("/")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CollectionConfig(_Strict):
|
|
181
|
+
"""One kind of Kiro content that is synced as a unit.
|
|
182
|
+
|
|
183
|
+
A collection maps a directory in the workspace (and optionally one under the
|
|
184
|
+
Kiro home) onto a directory in the central repository, and says which files
|
|
185
|
+
inside it are synced and how they are validated.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
enabled: bool = True
|
|
189
|
+
description: str = Field(default="", max_length=200)
|
|
190
|
+
# Directory relative to the workspace root; ``null`` means the collection has no
|
|
191
|
+
# workspace scope (it is only synced globally).
|
|
192
|
+
workspace_path: str | None = None
|
|
193
|
+
# Directory relative to the Kiro home (``KIRO_HOME`` or ``~/.kiro``); ``null`` means
|
|
194
|
+
# the collection has no global scope.
|
|
195
|
+
global_path: str | None = None
|
|
196
|
+
# Directory under ``global/`` and ``projects/<slug>/`` in the central repository.
|
|
197
|
+
# Defaults to the collection name.
|
|
198
|
+
central_dir: str | None = None
|
|
199
|
+
# Glob patterns relative to the collection directory. ``**`` spans directories.
|
|
200
|
+
include: tuple[str, ...] = ("**/*",)
|
|
201
|
+
exclude: tuple[str, ...] = ()
|
|
202
|
+
# Limit discovery to this many directory levels (1 = only the top level).
|
|
203
|
+
max_depth: int | None = Field(default=None, ge=1, le=64)
|
|
204
|
+
lint: LintProfile = "generic"
|
|
205
|
+
# Overrides ``lint.max_file_bytes`` for this collection.
|
|
206
|
+
max_file_bytes: int | None = Field(default=None, ge=1024, le=100_000_000)
|
|
207
|
+
# Add the workspace directory to ``.git/info/exclude`` on ``steersync init``.
|
|
208
|
+
git_exclude: bool = True
|
|
209
|
+
|
|
210
|
+
@field_validator("workspace_path", "global_path", "central_dir")
|
|
211
|
+
@classmethod
|
|
212
|
+
def _relative(cls, value: str | None) -> str | None:
|
|
213
|
+
return None if value is None else _relative_path(value)
|
|
214
|
+
|
|
215
|
+
@field_validator("include", "exclude")
|
|
216
|
+
@classmethod
|
|
217
|
+
def _patterns(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
218
|
+
for pattern in value:
|
|
219
|
+
if not pattern.strip() or pattern.startswith(("/", "\\")) or "\\" in pattern:
|
|
220
|
+
msg = f"invalid glob pattern {pattern!r}: patterns are relative posix globs"
|
|
221
|
+
raise ValueError(msg)
|
|
222
|
+
if ".." in PurePosixPath(pattern).parts or "\0" in pattern:
|
|
223
|
+
msg = f"invalid glob pattern {pattern!r}: parent references are not allowed"
|
|
224
|
+
raise ValueError(msg)
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
@field_validator("include")
|
|
228
|
+
@classmethod
|
|
229
|
+
def _not_empty(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
230
|
+
if not value:
|
|
231
|
+
msg = "include must list at least one glob pattern"
|
|
232
|
+
raise ValueError(msg)
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class PathsConfig(_Strict):
|
|
237
|
+
workspace_lock: str = ".kiro/steersync.lock.json"
|
|
238
|
+
global_lock: str = "steersync.lock.json"
|
|
239
|
+
cache_dir: str = "~/.cache/steersync"
|
|
240
|
+
|
|
241
|
+
@field_validator("workspace_lock", "global_lock")
|
|
242
|
+
@classmethod
|
|
243
|
+
def _relative(cls, value: str) -> str:
|
|
244
|
+
return _relative_path(value)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class SyncConfig(_Strict):
|
|
248
|
+
prune: bool = True
|
|
249
|
+
strict_pull: bool = False
|
|
250
|
+
lock_timeout_seconds: int = Field(default=120, ge=1, le=3600)
|
|
251
|
+
max_files: int = Field(default=1000, ge=1, le=100_000)
|
|
252
|
+
max_total_bytes: int = Field(default=10_485_760, ge=1024, le=1_073_741_824)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class HookConfig(_Strict):
|
|
256
|
+
strict: bool = False
|
|
257
|
+
timeout_seconds: int = Field(default=60, ge=5, le=3600)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class LoggingConfig(_Strict):
|
|
261
|
+
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
262
|
+
format: Literal["text", "json"] = "text"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Config(_Strict):
|
|
266
|
+
"""Fully validated steersync configuration."""
|
|
267
|
+
|
|
268
|
+
remote: RemoteConfig = RemoteConfig()
|
|
269
|
+
git: GitConfig = GitConfig()
|
|
270
|
+
branch: BranchConfig = BranchConfig()
|
|
271
|
+
paths: PathsConfig = PathsConfig()
|
|
272
|
+
collections: dict[str, CollectionConfig] = {}
|
|
273
|
+
sync: SyncConfig = SyncConfig()
|
|
274
|
+
lint: LintConfig = LintConfig()
|
|
275
|
+
hook: HookConfig = HookConfig()
|
|
276
|
+
logging: LoggingConfig = LoggingConfig()
|
|
277
|
+
source_file: Path | None = None
|
|
278
|
+
|
|
279
|
+
@field_validator("collections", mode="before")
|
|
280
|
+
@classmethod
|
|
281
|
+
def _default_central_dir(cls, value: object) -> object:
|
|
282
|
+
if not isinstance(value, dict):
|
|
283
|
+
return value
|
|
284
|
+
filled: dict[str, Any] = {}
|
|
285
|
+
for name, raw in value.items():
|
|
286
|
+
needs_default = isinstance(raw, dict) and raw.get("central_dir") is None
|
|
287
|
+
filled[name] = {**raw, "central_dir": name} if needs_default else raw
|
|
288
|
+
return filled
|
|
289
|
+
|
|
290
|
+
@field_validator("collections")
|
|
291
|
+
@classmethod
|
|
292
|
+
def _collections(cls, value: dict[str, CollectionConfig]) -> dict[str, CollectionConfig]:
|
|
293
|
+
seen: dict[str, dict[str, str]] = {
|
|
294
|
+
"central_dir": {},
|
|
295
|
+
"workspace_path": {},
|
|
296
|
+
"global_path": {},
|
|
297
|
+
}
|
|
298
|
+
for name, collection in value.items():
|
|
299
|
+
if not _COLLECTION_NAME_RE.match(name):
|
|
300
|
+
msg = f"invalid collection name {name!r}: use lowercase letters, digits and dashes"
|
|
301
|
+
raise ValueError(msg)
|
|
302
|
+
if collection.workspace_path is None and collection.global_path is None:
|
|
303
|
+
msg = f"collection {name!r} needs a workspace_path or a global_path"
|
|
304
|
+
raise ValueError(msg)
|
|
305
|
+
if not collection.enabled:
|
|
306
|
+
continue
|
|
307
|
+
for key, used in seen.items():
|
|
308
|
+
path = getattr(collection, key)
|
|
309
|
+
if path is None:
|
|
310
|
+
continue
|
|
311
|
+
if path in used:
|
|
312
|
+
msg = f"collections {used[path]!r} and {name!r} share the same {key} {path!r}"
|
|
313
|
+
raise ValueError(msg)
|
|
314
|
+
used[path] = name
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
def enabled_collections(self) -> dict[str, CollectionConfig]:
|
|
318
|
+
"""Return the enabled collections in configuration order."""
|
|
319
|
+
return {name: c for name, c in self.collections.items() if c.enabled}
|
|
320
|
+
|
|
321
|
+
def select_collections(self, names: Sequence[str] | None) -> dict[str, CollectionConfig]:
|
|
322
|
+
"""Return the enabled collections named in ``names`` (all of them when empty)."""
|
|
323
|
+
enabled = self.enabled_collections()
|
|
324
|
+
if not names:
|
|
325
|
+
return enabled
|
|
326
|
+
unknown = [n for n in names if n not in self.collections]
|
|
327
|
+
if unknown:
|
|
328
|
+
msg = f"unknown collection(s): {', '.join(sorted(unknown))}"
|
|
329
|
+
raise ConfigError(msg)
|
|
330
|
+
disabled = [n for n in names if n not in enabled]
|
|
331
|
+
if disabled:
|
|
332
|
+
msg = f"collection(s) disabled in configuration: {', '.join(sorted(disabled))}"
|
|
333
|
+
raise ConfigError(msg)
|
|
334
|
+
return {name: enabled[name] for name in dict.fromkeys(names)}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def default_config_text() -> str:
|
|
338
|
+
"""Return the packaged default configuration as YAML text."""
|
|
339
|
+
return resources.files("steersync.resources").joinpath("default_config.yaml").read_text("utf-8")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _load_yaml(text: str, origin: str) -> dict[str, Any]:
|
|
343
|
+
try:
|
|
344
|
+
data = yaml.safe_load(text)
|
|
345
|
+
except yaml.YAMLError as exc:
|
|
346
|
+
msg = f"{origin}: invalid YAML: {exc}"
|
|
347
|
+
raise ConfigError(msg) from exc
|
|
348
|
+
if data is None:
|
|
349
|
+
return {}
|
|
350
|
+
if not isinstance(data, dict):
|
|
351
|
+
msg = f"{origin}: top level must be a mapping"
|
|
352
|
+
raise ConfigError(msg)
|
|
353
|
+
return data
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
357
|
+
"""Return ``base`` with ``override`` merged in. Nested mappings merge recursively."""
|
|
358
|
+
merged: dict[str, Any] = dict(base)
|
|
359
|
+
for key, value in override.items():
|
|
360
|
+
existing = merged.get(key)
|
|
361
|
+
if isinstance(existing, Mapping) and isinstance(value, Mapping):
|
|
362
|
+
merged[key] = deep_merge(existing, value)
|
|
363
|
+
else:
|
|
364
|
+
merged[key] = value
|
|
365
|
+
return merged
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _set_nested(data: dict[str, Any], dotted: str, value: Any) -> None: # noqa: ANN401
|
|
369
|
+
node = data
|
|
370
|
+
*parents, leaf = dotted.split(".")
|
|
371
|
+
for part in parents:
|
|
372
|
+
child = node.get(part)
|
|
373
|
+
if not isinstance(child, dict):
|
|
374
|
+
child = {}
|
|
375
|
+
node[part] = child
|
|
376
|
+
node = child
|
|
377
|
+
node[leaf] = value
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _env_overrides(env: Mapping[str, str]) -> dict[str, Any]:
|
|
381
|
+
mapping = {
|
|
382
|
+
ENV_REMOTE: "remote.url",
|
|
383
|
+
ENV_DEFAULT_BRANCH: "remote.default_branch",
|
|
384
|
+
ENV_CACHE_DIR: "paths.cache_dir",
|
|
385
|
+
ENV_LOG_LEVEL: "logging.level",
|
|
386
|
+
ENV_LOG_FORMAT: "logging.format",
|
|
387
|
+
ENV_GIT_BINARY: "git.binary",
|
|
388
|
+
}
|
|
389
|
+
overrides: dict[str, Any] = {}
|
|
390
|
+
for env_name, dotted in mapping.items():
|
|
391
|
+
raw = env.get(env_name)
|
|
392
|
+
if raw is not None and raw != "":
|
|
393
|
+
value: Any = raw.upper() if dotted == "logging.level" else raw
|
|
394
|
+
_set_nested(overrides, dotted, value)
|
|
395
|
+
strict = env.get(ENV_STRICT)
|
|
396
|
+
if strict is not None:
|
|
397
|
+
_set_nested(overrides, "hook.strict", strict.strip().lower() in {"1", "true", "yes", "on"})
|
|
398
|
+
return overrides
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def find_config_file(
|
|
402
|
+
workspace_root: Path | None,
|
|
403
|
+
env: Mapping[str, str],
|
|
404
|
+
explicit: Path | None = None,
|
|
405
|
+
) -> Path | None:
|
|
406
|
+
"""Locate the configuration file using the documented precedence."""
|
|
407
|
+
if explicit is not None:
|
|
408
|
+
if not explicit.is_file():
|
|
409
|
+
msg = f"configuration file not found: {explicit}"
|
|
410
|
+
raise ConfigError(msg)
|
|
411
|
+
return explicit
|
|
412
|
+
from_env = env.get(ENV_CONFIG)
|
|
413
|
+
if from_env:
|
|
414
|
+
candidate = Path(from_env).expanduser()
|
|
415
|
+
if not candidate.is_file():
|
|
416
|
+
msg = f"{ENV_CONFIG} points to a missing file: {candidate}"
|
|
417
|
+
raise ConfigError(msg)
|
|
418
|
+
return candidate
|
|
419
|
+
candidates: list[Path] = []
|
|
420
|
+
if workspace_root is not None:
|
|
421
|
+
candidates.extend(workspace_root / ".kiro" / name for name in CONFIG_FILE_NAMES)
|
|
422
|
+
xdg = env.get("XDG_CONFIG_HOME")
|
|
423
|
+
config_home = (
|
|
424
|
+
Path(xdg).expanduser() if xdg else Path(env.get("HOME", "~")).expanduser() / ".config"
|
|
425
|
+
)
|
|
426
|
+
candidates.extend(config_home / "steersync" / name for name in ("config.yaml", "config.yml"))
|
|
427
|
+
return next((c for c in candidates if c.is_file()), None)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def load_config(
|
|
431
|
+
*,
|
|
432
|
+
workspace_root: Path | None = None,
|
|
433
|
+
env: Mapping[str, str] | None = None,
|
|
434
|
+
explicit: Path | None = None,
|
|
435
|
+
) -> Config:
|
|
436
|
+
"""Load, merge and validate configuration."""
|
|
437
|
+
environment = os.environ if env is None else env
|
|
438
|
+
data = _load_yaml(default_config_text(), "packaged defaults")
|
|
439
|
+
source = find_config_file(workspace_root, environment, explicit)
|
|
440
|
+
if source is not None:
|
|
441
|
+
try:
|
|
442
|
+
text = source.read_text("utf-8")
|
|
443
|
+
except OSError as exc:
|
|
444
|
+
msg = f"cannot read configuration file {source}: {exc}"
|
|
445
|
+
raise ConfigError(msg) from exc
|
|
446
|
+
data = deep_merge(data, _load_yaml(text, str(source)))
|
|
447
|
+
data = deep_merge(data, _env_overrides(environment))
|
|
448
|
+
data = _migrate_legacy_paths(data, str(source) if source else "configuration")
|
|
449
|
+
data["source_file"] = source
|
|
450
|
+
try:
|
|
451
|
+
return Config.model_validate(data)
|
|
452
|
+
except ValidationError as exc:
|
|
453
|
+
details = "; ".join(
|
|
454
|
+
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
|
|
455
|
+
)
|
|
456
|
+
msg = f"invalid configuration: {details}"
|
|
457
|
+
raise ConfigError(msg) from exc
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _migrate_legacy_paths(data: dict[str, Any], origin: str) -> dict[str, Any]:
|
|
461
|
+
"""Map the pre 0.2 ``paths.*_steering`` keys onto the steering collection."""
|
|
462
|
+
paths_section = data.get("paths")
|
|
463
|
+
if not isinstance(paths_section, dict):
|
|
464
|
+
return data
|
|
465
|
+
legacy = {k: v for k, v in paths_section.items() if k in _LEGACY_PATH_KEYS}
|
|
466
|
+
if not legacy:
|
|
467
|
+
return data
|
|
468
|
+
migrated = dict(data)
|
|
469
|
+
migrated["paths"] = {k: v for k, v in paths_section.items() if k not in _LEGACY_PATH_KEYS}
|
|
470
|
+
collections = dict(migrated.get("collections") or {})
|
|
471
|
+
for key, value in legacy.items():
|
|
472
|
+
collection, setting = _LEGACY_PATH_KEYS[key]
|
|
473
|
+
existing = collections.get(collection)
|
|
474
|
+
target = dict(existing) if isinstance(existing, dict) else {}
|
|
475
|
+
target[setting] = value
|
|
476
|
+
collections[collection] = target
|
|
477
|
+
log.warning(
|
|
478
|
+
"%s: paths.%s is deprecated; use collections.%s.%s", origin, key, collection, setting
|
|
479
|
+
)
|
|
480
|
+
migrated["collections"] = collections
|
|
481
|
+
return migrated
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def validate_remote_url(
|
|
485
|
+
url: str, *, allowed_hosts: tuple[str, ...] = (), allow_local: bool = False
|
|
486
|
+
) -> str:
|
|
487
|
+
"""Validate a git remote URL and return it unchanged.
|
|
488
|
+
|
|
489
|
+
Only ssh://, https://, git@host:path and (optionally) local file URLs are accepted.
|
|
490
|
+
Anything that could be interpreted as a git option or a helper transport is rejected.
|
|
491
|
+
"""
|
|
492
|
+
candidate = url.strip()
|
|
493
|
+
if not candidate:
|
|
494
|
+
msg = "remote URL is empty; set remote.url in configuration or STEERSYNC_REMOTE"
|
|
495
|
+
raise ConfigError(msg)
|
|
496
|
+
if candidate.startswith("-") or any(c in candidate for c in "\n\r\0 \t"):
|
|
497
|
+
msg = "remote URL contains illegal characters"
|
|
498
|
+
raise ConfigError(msg)
|
|
499
|
+
if "::" in candidate:
|
|
500
|
+
msg = "remote helper transports (ext::, fd::) are not allowed"
|
|
501
|
+
raise ConfigError(msg)
|
|
502
|
+
host = _remote_host(candidate, allow_local=allow_local)
|
|
503
|
+
if allowed_hosts and host and host.lower() not in allowed_hosts:
|
|
504
|
+
msg = f"remote host {host!r} is not in remote.allowed_hosts"
|
|
505
|
+
raise ConfigError(msg)
|
|
506
|
+
return candidate
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _remote_host(url: str, *, allow_local: bool) -> str | None:
|
|
510
|
+
parts = urlsplit(url)
|
|
511
|
+
if parts.scheme in {"ssh", "https", "git+ssh", "ssh+git"}:
|
|
512
|
+
if not parts.hostname:
|
|
513
|
+
msg = f"remote URL has no host: {url}"
|
|
514
|
+
raise ConfigError(msg)
|
|
515
|
+
return parts.hostname
|
|
516
|
+
if parts.scheme == "file" or url.startswith("/"):
|
|
517
|
+
if not allow_local:
|
|
518
|
+
msg = "local remotes are disabled; set remote.allow_local to true to permit them"
|
|
519
|
+
raise ConfigError(msg)
|
|
520
|
+
return None
|
|
521
|
+
if parts.scheme in {"http", "git", "ftp", "ftps"}:
|
|
522
|
+
msg = f"insecure remote scheme {parts.scheme!r} is not allowed"
|
|
523
|
+
raise ConfigError(msg)
|
|
524
|
+
if _SCP_LIKE.match(url):
|
|
525
|
+
return url.split(":", 1)[0].rsplit("@", 1)[-1]
|
|
526
|
+
msg = f"unsupported remote URL: {url}"
|
|
527
|
+
raise ConfigError(msg)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def remote_host(url: str) -> str | None:
|
|
531
|
+
"""Return the host of a validated remote URL, or ``None`` for local remotes."""
|
|
532
|
+
return _remote_host(url, allow_local=True)
|
steersync/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Exception types raised by steersync.
|
|
2
|
+
|
|
3
|
+
Each error carries a process exit code so the CLI can map failures consistently.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SteersyncError(Exception):
|
|
8
|
+
"""Base error for all steersync failures."""
|
|
9
|
+
|
|
10
|
+
exit_code: int = 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(SteersyncError):
|
|
14
|
+
"""Configuration is missing, malformed or fails validation."""
|
|
15
|
+
|
|
16
|
+
exit_code = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitError(SteersyncError):
|
|
20
|
+
"""A git invocation failed or returned unexpected output."""
|
|
21
|
+
|
|
22
|
+
exit_code = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LintError(SteersyncError):
|
|
26
|
+
"""Steering documents failed validation."""
|
|
27
|
+
|
|
28
|
+
exit_code = 4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SyncConflictError(SteersyncError):
|
|
32
|
+
"""Local and remote steering changed in incompatible ways."""
|
|
33
|
+
|
|
34
|
+
exit_code = 5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnsafePathError(SteersyncError):
|
|
38
|
+
"""A path escaped the directory it must stay inside."""
|
|
39
|
+
|
|
40
|
+
exit_code = 6
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LimitExceededError(SteersyncError):
|
|
44
|
+
"""Content from the central repository exceeds the configured safety limits."""
|
|
45
|
+
|
|
46
|
+
exit_code = 7
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LockTimeoutError(SteersyncError):
|
|
50
|
+
"""Another steersync process held the cache lock for too long."""
|
|
51
|
+
|
|
52
|
+
exit_code = 8
|