skillmate 12.0.1.dev147__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.
- skillmate/__init__.py +3 -0
- skillmate/__main__.py +3 -0
- skillmate/adapters.py +169 -0
- skillmate/checkout.py +66 -0
- skillmate/cli.py +301 -0
- skillmate/content.py +124 -0
- skillmate/distribution.py +216 -0
- skillmate/installer.py +301 -0
- skillmate/opencode_config.py +404 -0
- skillmate/py.typed +0 -0
- skillmate/safety.py +298 -0
- skillmate-12.0.1.dev147.dist-info/METADATA +23 -0
- skillmate-12.0.1.dev147.dist-info/RECORD +15 -0
- skillmate-12.0.1.dev147.dist-info/WHEEL +4 -0
- skillmate-12.0.1.dev147.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""OpenCode configuration fragments, ported from the legacy config-setup contract.
|
|
2
|
+
|
|
3
|
+
The retired integration's behaviors that survive here (its REQ-F-514): only
|
|
4
|
+
absent preset values are added; user entries, comments, and formatting
|
|
5
|
+
outside the edit are preserved; an existing different value at a fragment
|
|
6
|
+
path is a conflict that is reported and never merged; repeated application
|
|
7
|
+
is a byte-identical no-op; every written document re-parses as JSONC.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
type JSONValue = str | int | float | bool | list[JSONValue] | dict[str, JSONValue] | None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigError(Exception):
|
|
20
|
+
"""An unreadable configuration document or a malformed fragment."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigConflictError(ConfigError):
|
|
24
|
+
"""The document already carries a different value at a fragment path."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class FragmentEntry:
|
|
29
|
+
"""One preset value: ``key`` inside the object at ``path`` (root when empty)."""
|
|
30
|
+
|
|
31
|
+
path: tuple[str, ...]
|
|
32
|
+
key: str
|
|
33
|
+
value: JSONValue
|
|
34
|
+
|
|
35
|
+
def label(self) -> str:
|
|
36
|
+
return ".".join((*self.path, self.key))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ConfigFragment:
|
|
41
|
+
"""A named set of preset values applied together or not at all."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
entries: tuple[FragmentEntry, ...]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Evaluation:
|
|
49
|
+
"""What applying a fragment to a document would do."""
|
|
50
|
+
|
|
51
|
+
operations: tuple[str, ...]
|
|
52
|
+
conflicts: tuple[str, ...]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def clean(self) -> bool:
|
|
56
|
+
return not self.conflicts
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def changes_needed(self) -> bool:
|
|
60
|
+
return bool(self.operations)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def strip_comments(text: str) -> str:
|
|
64
|
+
"""Blank out // and /* */ comments outside strings, preserving positions."""
|
|
65
|
+
output: list[str] = []
|
|
66
|
+
index = 0
|
|
67
|
+
length = len(text)
|
|
68
|
+
in_string = False
|
|
69
|
+
while index < length:
|
|
70
|
+
char = text[index]
|
|
71
|
+
if in_string:
|
|
72
|
+
output.append(char)
|
|
73
|
+
if char == "\\" and index + 1 < length:
|
|
74
|
+
output.append(text[index + 1])
|
|
75
|
+
index += 2
|
|
76
|
+
continue
|
|
77
|
+
if char == '"':
|
|
78
|
+
in_string = False
|
|
79
|
+
index += 1
|
|
80
|
+
continue
|
|
81
|
+
if char == '"':
|
|
82
|
+
in_string = True
|
|
83
|
+
output.append(char)
|
|
84
|
+
index += 1
|
|
85
|
+
continue
|
|
86
|
+
if char == "/" and index + 1 < length and text[index + 1] == "/":
|
|
87
|
+
while index < length and text[index] != "\n":
|
|
88
|
+
output.append(" ")
|
|
89
|
+
index += 1
|
|
90
|
+
continue
|
|
91
|
+
if char == "/" and index + 1 < length and text[index + 1] == "*":
|
|
92
|
+
end = text.find("*/", index + 2)
|
|
93
|
+
end = length if end == -1 else end + 2
|
|
94
|
+
for position in range(index, end):
|
|
95
|
+
output.append("\n" if text[position] == "\n" else " ")
|
|
96
|
+
index = end
|
|
97
|
+
continue
|
|
98
|
+
output.append(char)
|
|
99
|
+
index += 1
|
|
100
|
+
return "".join(output)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_jsonc(text: str) -> JSONValue:
|
|
104
|
+
"""Parse a JSONC document (comments and trailing commas tolerated)."""
|
|
105
|
+
stripped = strip_comments(text).strip()
|
|
106
|
+
stripped = re.sub(r",\s*([}\]])", r"\1", stripped)
|
|
107
|
+
if not stripped:
|
|
108
|
+
raise ConfigError("configuration document is empty")
|
|
109
|
+
try:
|
|
110
|
+
value: JSONValue = json.loads(stripped)
|
|
111
|
+
except json.JSONDecodeError as error:
|
|
112
|
+
raise ConfigError(f"configuration document is not valid JSONC: {error}") from error
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class _Token:
|
|
118
|
+
kind: str # "string" | "literal" | "punct" | "comment" | "ws"
|
|
119
|
+
start: int
|
|
120
|
+
end: int
|
|
121
|
+
text: str = ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _tokenize(text: str) -> list[_Token]:
|
|
125
|
+
"""Split JSONC text into tokens carrying original offsets."""
|
|
126
|
+
tokens: list[_Token] = []
|
|
127
|
+
index = 0
|
|
128
|
+
length = len(text)
|
|
129
|
+
while index < length:
|
|
130
|
+
char = text[index]
|
|
131
|
+
if char.isspace():
|
|
132
|
+
start = index
|
|
133
|
+
while index < length and text[index].isspace():
|
|
134
|
+
index += 1
|
|
135
|
+
tokens.append(_Token("ws", start, index))
|
|
136
|
+
continue
|
|
137
|
+
if char == '"':
|
|
138
|
+
start = index
|
|
139
|
+
index += 1
|
|
140
|
+
while index < length:
|
|
141
|
+
if text[index] == "\\":
|
|
142
|
+
index += 2
|
|
143
|
+
continue
|
|
144
|
+
if text[index] == '"':
|
|
145
|
+
index += 1
|
|
146
|
+
break
|
|
147
|
+
index += 1
|
|
148
|
+
tokens.append(_Token("string", start, index, text[start:index]))
|
|
149
|
+
continue
|
|
150
|
+
if char == "/" and index + 1 < length and text[index + 1] == "/":
|
|
151
|
+
start = index
|
|
152
|
+
while index < length and text[index] != "\n":
|
|
153
|
+
index += 1
|
|
154
|
+
tokens.append(_Token("comment", start, index))
|
|
155
|
+
continue
|
|
156
|
+
if char == "/" and index + 1 < length and text[index + 1] == "*":
|
|
157
|
+
start = index
|
|
158
|
+
end = text.find("*/", index + 2)
|
|
159
|
+
index = length if end == -1 else end + 2
|
|
160
|
+
tokens.append(_Token("comment", start, index))
|
|
161
|
+
continue
|
|
162
|
+
if char in "{}[],:":
|
|
163
|
+
tokens.append(_Token("punct", index, index + 1, char))
|
|
164
|
+
index += 1
|
|
165
|
+
continue
|
|
166
|
+
start = index
|
|
167
|
+
while index < length and not text[index].isspace() and text[index] not in '{}[],:"/':
|
|
168
|
+
index += 1
|
|
169
|
+
tokens.append(_Token("literal", start, index, text[start:index]))
|
|
170
|
+
return tokens
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class _Frame:
|
|
175
|
+
kind: str # "object" | "array"
|
|
176
|
+
path: tuple[str, ...]
|
|
177
|
+
open_start: int = 0
|
|
178
|
+
pending_key: str | None = None
|
|
179
|
+
first_child: int | None = None
|
|
180
|
+
last_child_end: int | None = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class _Span:
|
|
185
|
+
is_object: bool
|
|
186
|
+
close_offset: int
|
|
187
|
+
first_child: int | None
|
|
188
|
+
last_child_end: int | None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _structure(tokens: list[_Token]) -> dict[tuple[str, ...], _Span]:
|
|
192
|
+
"""Map every object and array path to its span in the original text."""
|
|
193
|
+
significant = [token for token in tokens if token.kind not in ("ws", "comment")]
|
|
194
|
+
spans: dict[tuple[str, ...], _Span] = {}
|
|
195
|
+
stack: list[_Frame] = []
|
|
196
|
+
index = 0
|
|
197
|
+
while index < len(significant):
|
|
198
|
+
token = significant[index]
|
|
199
|
+
frame = stack[-1] if stack else None
|
|
200
|
+
if token.text in ("{", "["):
|
|
201
|
+
if frame is None:
|
|
202
|
+
path: tuple[str, ...] = ()
|
|
203
|
+
elif frame.kind == "object":
|
|
204
|
+
if frame.pending_key is None:
|
|
205
|
+
raise ConfigError("object opens without a key where one is required")
|
|
206
|
+
path = (*frame.path, frame.pending_key)
|
|
207
|
+
frame.pending_key = None
|
|
208
|
+
else:
|
|
209
|
+
path = frame.path
|
|
210
|
+
stack.append(
|
|
211
|
+
_Frame(
|
|
212
|
+
kind="object" if token.text == "{" else "array",
|
|
213
|
+
path=path,
|
|
214
|
+
open_start=token.start,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
index += 1
|
|
218
|
+
continue
|
|
219
|
+
if token.text in ("}", "]"):
|
|
220
|
+
closing = stack.pop()
|
|
221
|
+
spans[closing.path] = _Span(
|
|
222
|
+
is_object=closing.kind == "object",
|
|
223
|
+
close_offset=token.start,
|
|
224
|
+
first_child=closing.first_child,
|
|
225
|
+
last_child_end=closing.last_child_end,
|
|
226
|
+
)
|
|
227
|
+
parent = stack[-1] if stack else None
|
|
228
|
+
if parent is not None:
|
|
229
|
+
_note_child(parent, closing.open_start, token.end)
|
|
230
|
+
index += 1
|
|
231
|
+
continue
|
|
232
|
+
if frame is None:
|
|
233
|
+
index += 1
|
|
234
|
+
continue
|
|
235
|
+
if (
|
|
236
|
+
frame.kind == "object"
|
|
237
|
+
and token.kind == "string"
|
|
238
|
+
and index + 1 < len(significant)
|
|
239
|
+
and significant[index + 1].text == ":"
|
|
240
|
+
):
|
|
241
|
+
frame.pending_key = _decode_string(token)
|
|
242
|
+
elif token.text not in {":", ","}:
|
|
243
|
+
_note_child(frame, token.start, token.end)
|
|
244
|
+
index += 1
|
|
245
|
+
return spans
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _note_child(frame: _Frame, start: int, end: int) -> None:
|
|
249
|
+
if frame.first_child is None:
|
|
250
|
+
frame.first_child = start
|
|
251
|
+
frame.last_child_end = end
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _decode_string(token: _Token) -> str:
|
|
255
|
+
try:
|
|
256
|
+
value = json.loads(token.text)
|
|
257
|
+
except json.JSONDecodeError as error: # pragma: no cover - tokenizer guarantees form
|
|
258
|
+
raise ConfigError(f"unreadable string token: {error}") from error
|
|
259
|
+
return value if isinstance(value, str) else ""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _navigate(document: JSONValue, path: tuple[str, ...]) -> JSONValue | None:
|
|
263
|
+
current: JSONValue = document
|
|
264
|
+
for key in path:
|
|
265
|
+
if not isinstance(current, dict) or key not in current:
|
|
266
|
+
return None
|
|
267
|
+
current = current[key]
|
|
268
|
+
return current
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def evaluate(text: str, fragment: ConfigFragment) -> Evaluation:
|
|
272
|
+
"""Report the operations and conflicts a fragment has against a document."""
|
|
273
|
+
document = parse_jsonc(text)
|
|
274
|
+
if not isinstance(document, dict):
|
|
275
|
+
raise ConfigError("the configuration document root must be an object")
|
|
276
|
+
operations: list[str] = []
|
|
277
|
+
conflicts: list[str] = []
|
|
278
|
+
for entry in fragment.entries:
|
|
279
|
+
parent = _navigate(document, entry.path)
|
|
280
|
+
if parent is None:
|
|
281
|
+
operations.append(f"create {entry.label()}")
|
|
282
|
+
continue
|
|
283
|
+
if not isinstance(parent, dict):
|
|
284
|
+
raise ConfigError(f"fragment path {'.'.join(entry.path)} is not an object")
|
|
285
|
+
if entry.key in parent:
|
|
286
|
+
if parent[entry.key] != entry.value:
|
|
287
|
+
conflicts.append(f"{entry.label()}: present value differs from the fragment preset")
|
|
288
|
+
else:
|
|
289
|
+
operations.append(f"set {entry.label()}")
|
|
290
|
+
return Evaluation(operations=tuple(operations), conflicts=tuple(conflicts))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _render_entry(entry: FragmentEntry) -> str:
|
|
294
|
+
return f'"{entry.key}": {json.dumps(entry.value, ensure_ascii=False)}'
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _indent_at(text: str, offset: int) -> str:
|
|
298
|
+
line_start = text.rfind("\n", 0, offset) + 1
|
|
299
|
+
indent = ""
|
|
300
|
+
for char in text[line_start:offset]:
|
|
301
|
+
if char not in " \t":
|
|
302
|
+
break
|
|
303
|
+
indent += char
|
|
304
|
+
return indent
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def apply_fragment(text: str, fragment: ConfigFragment) -> str:
|
|
308
|
+
"""Apply a fragment to a JSONC document, preserving everything else.
|
|
309
|
+
|
|
310
|
+
Conflicts abort with :class:`ConfigConflictError`; a document needing no
|
|
311
|
+
change comes back byte-identical. The comma after the last existing
|
|
312
|
+
value lands before that value's trailing comment, and new lines land
|
|
313
|
+
after it, so line comments stay attached to what they annotate.
|
|
314
|
+
"""
|
|
315
|
+
evaluation = evaluate(text, fragment)
|
|
316
|
+
if evaluation.conflicts:
|
|
317
|
+
raise ConfigConflictError(
|
|
318
|
+
f"fragment {fragment.name!r} conflicts: " + "; ".join(evaluation.conflicts)
|
|
319
|
+
)
|
|
320
|
+
if not evaluation.operations:
|
|
321
|
+
return text
|
|
322
|
+
|
|
323
|
+
document = parse_jsonc(text)
|
|
324
|
+
tokens = _tokenize(text)
|
|
325
|
+
spans = _structure(tokens)
|
|
326
|
+
lines_by_container: dict[tuple[str, ...], list[str]] = {}
|
|
327
|
+
trees_by_container: dict[tuple[str, ...], dict[str, JSONValue]] = {}
|
|
328
|
+
for entry in fragment.entries:
|
|
329
|
+
parent = _navigate(document, entry.path)
|
|
330
|
+
if parent is None:
|
|
331
|
+
ancestor = _deepest_ancestor(spans, entry.path)
|
|
332
|
+
tree = trees_by_container.setdefault(ancestor, {})
|
|
333
|
+
node: dict[str, JSONValue] = tree
|
|
334
|
+
for key in entry.path[len(ancestor) :]:
|
|
335
|
+
existing = node.get(key)
|
|
336
|
+
child: dict[str, JSONValue] = existing if isinstance(existing, dict) else {}
|
|
337
|
+
node[key] = child
|
|
338
|
+
node = child
|
|
339
|
+
node[entry.key] = entry.value
|
|
340
|
+
elif not isinstance(parent, dict):
|
|
341
|
+
raise ConfigError(f"fragment path {'.'.join(entry.path)} is not an object")
|
|
342
|
+
elif entry.key not in parent:
|
|
343
|
+
lines_by_container.setdefault(entry.path, []).append(_render_entry(entry))
|
|
344
|
+
for ancestor, tree in trees_by_container.items():
|
|
345
|
+
for key, subtree in tree.items():
|
|
346
|
+
lines_by_container.setdefault(ancestor, []).append(
|
|
347
|
+
f"{json.dumps(key, ensure_ascii=False)}: {json.dumps(subtree, ensure_ascii=False)}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
insertions: list[tuple[int, str]] = []
|
|
351
|
+
for path, lines in lines_by_container.items():
|
|
352
|
+
span = spans.get(path)
|
|
353
|
+
if span is None:
|
|
354
|
+
raise ConfigError(f"fragment container {'.'.join(path)} is missing from the document")
|
|
355
|
+
if not span.is_object:
|
|
356
|
+
raise ConfigError(f"fragment container {'.'.join(path)} is not an object")
|
|
357
|
+
container_indent = _indent_at(text, span.close_offset)
|
|
358
|
+
child_indent = _indent_at(text, span.first_child or span.close_offset)
|
|
359
|
+
body = (",\n" + child_indent).join(lines)
|
|
360
|
+
if span.last_child_end is not None:
|
|
361
|
+
insertions.append((span.last_child_end, ","))
|
|
362
|
+
content_end = _content_end(tokens, span.last_child_end, span.close_offset)
|
|
363
|
+
insertions.append((content_end, f"\n{child_indent}{body}"))
|
|
364
|
+
else:
|
|
365
|
+
insertions.append((span.close_offset, f"\n{child_indent}{body}\n{container_indent}"))
|
|
366
|
+
# Descending offsets keep earlier spans valid; at equal offsets the
|
|
367
|
+
# comma lands closest to the anchor, so it ends up before the new lines.
|
|
368
|
+
ordered = sorted(
|
|
369
|
+
insertions,
|
|
370
|
+
key=lambda item: (item[0], 0 if item[1] == "," else 1),
|
|
371
|
+
reverse=True,
|
|
372
|
+
)
|
|
373
|
+
for offset, insertion in ordered:
|
|
374
|
+
text = text[:offset] + insertion + text[offset:]
|
|
375
|
+
|
|
376
|
+
result = parse_jsonc(text)
|
|
377
|
+
if not isinstance(result, dict):
|
|
378
|
+
raise ConfigError("merged configuration does not reparse as an object")
|
|
379
|
+
for entry in fragment.entries:
|
|
380
|
+
current = _navigate(result, entry.path)
|
|
381
|
+
if not isinstance(current, dict) or current.get(entry.key) != entry.value:
|
|
382
|
+
raise ConfigError(f"merged configuration lost the preset {entry.label()}")
|
|
383
|
+
return text
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _content_end(tokens: list[_Token], last_child_end: int, close_offset: int) -> int:
|
|
387
|
+
"""End of the last content on the final line, including a trailing comment."""
|
|
388
|
+
end = last_child_end
|
|
389
|
+
for token in tokens:
|
|
390
|
+
if token.kind == "comment" and last_child_end <= token.start < close_offset:
|
|
391
|
+
end = max(end, token.end)
|
|
392
|
+
return end
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _deepest_ancestor(
|
|
396
|
+
spans: dict[tuple[str, ...], _Span], path: tuple[str, ...]
|
|
397
|
+
) -> tuple[str, ...]:
|
|
398
|
+
"""The longest prefix of ``path`` that exists as an object container."""
|
|
399
|
+
for length in range(len(path), -1, -1):
|
|
400
|
+
candidate = path[:length]
|
|
401
|
+
span = spans.get(candidate)
|
|
402
|
+
if span is not None and span.is_object:
|
|
403
|
+
return candidate
|
|
404
|
+
raise ConfigError(f"fragment path {'.'.join(path)} has no object ancestor in the document")
|
skillmate/py.typed
ADDED
|
File without changes
|
skillmate/safety.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""The safety model: preview, confirmation, ownership, collision, drift.
|
|
2
|
+
|
|
3
|
+
Every write path goes through one plan type. A plan is built from a desired
|
|
4
|
+
state and the recorded ownership; execution refuses to run unconfirmed, with
|
|
5
|
+
unresolved collisions, or with drifted files pending a drift decision. Files
|
|
6
|
+
skillmate does not own are never touched, and a file the user modified after
|
|
7
|
+
installation is never silently overwritten.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
|
|
18
|
+
MANIFEST_SCHEMA = "skillmate-ownership/v1"
|
|
19
|
+
MANIFEST_RELATIVE = ".skillmate/manifest.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SafetyError(Exception):
|
|
23
|
+
"""A plan cannot be executed as asked."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class OwnedFile:
|
|
28
|
+
path: str
|
|
29
|
+
sha256: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Ownership:
|
|
34
|
+
"""The recorded ownership of one host installation."""
|
|
35
|
+
|
|
36
|
+
distribution_version: str
|
|
37
|
+
source: str # "distribution" | "checkout"
|
|
38
|
+
files: tuple[OwnedFile, ...]
|
|
39
|
+
|
|
40
|
+
def by_path(self) -> dict[str, str]:
|
|
41
|
+
return {owned.path: owned.sha256 for owned in self.files}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def digest(data: bytes) -> str:
|
|
45
|
+
return hashlib.sha256(data).hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_ownership(root: Path) -> Ownership | None:
|
|
49
|
+
"""Read the ownership manifest of a host directory, if any."""
|
|
50
|
+
manifest = root / MANIFEST_RELATIVE
|
|
51
|
+
if not manifest.is_file():
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
document = json.loads(manifest.read_text(encoding="utf-8"))
|
|
55
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
56
|
+
raise SafetyError(f"ownership manifest at {manifest} is unreadable: {error}") from error
|
|
57
|
+
if document.get("schema") != MANIFEST_SCHEMA:
|
|
58
|
+
raise SafetyError(f"ownership manifest at {manifest} has an unknown schema")
|
|
59
|
+
version = document.get("version")
|
|
60
|
+
source = document.get("source")
|
|
61
|
+
files = document.get("files")
|
|
62
|
+
if not isinstance(version, str) or not isinstance(source, str) or not isinstance(files, list):
|
|
63
|
+
raise SafetyError(f"ownership manifest at {manifest} is malformed")
|
|
64
|
+
owned: list[OwnedFile] = []
|
|
65
|
+
for entry in files:
|
|
66
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
67
|
+
raise SafetyError(f"ownership manifest at {manifest} has a malformed file entry")
|
|
68
|
+
sha256 = entry.get("sha256")
|
|
69
|
+
if not isinstance(sha256, str):
|
|
70
|
+
raise SafetyError(f"ownership manifest at {manifest} has a file entry without a digest")
|
|
71
|
+
owned.append(OwnedFile(path=entry["path"], sha256=sha256))
|
|
72
|
+
return Ownership(distribution_version=version, source=source, files=tuple(owned))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class WriteStep:
|
|
77
|
+
path: str
|
|
78
|
+
content: bytes
|
|
79
|
+
|
|
80
|
+
def describe(self) -> str:
|
|
81
|
+
return f"write {self.path} ({len(self.content)} bytes)"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Plan:
|
|
86
|
+
"""A previewable, confirmable set of changes to one host directory."""
|
|
87
|
+
|
|
88
|
+
root: Path
|
|
89
|
+
steps: tuple[WriteStep, ...]
|
|
90
|
+
removals: tuple[str, ...]
|
|
91
|
+
collisions: tuple[str, ...]
|
|
92
|
+
drifted: tuple[str, ...]
|
|
93
|
+
missing: tuple[str, ...]
|
|
94
|
+
notices: tuple[str, ...]
|
|
95
|
+
ownership_after: Ownership | None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def empty(self) -> bool:
|
|
99
|
+
return not self.steps and not self.removals
|
|
100
|
+
|
|
101
|
+
def machine(self) -> dict[str, object]:
|
|
102
|
+
return {
|
|
103
|
+
"schema": "skillmate-plan/v1",
|
|
104
|
+
"root": str(self.root),
|
|
105
|
+
"steps": [step.describe() for step in self.steps],
|
|
106
|
+
"removals": list(self.removals),
|
|
107
|
+
"collisions": list(self.collisions),
|
|
108
|
+
"drifted": list(self.drifted),
|
|
109
|
+
"missing": list(self.missing),
|
|
110
|
+
"notices": list(self.notices),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def preview(self) -> str:
|
|
114
|
+
lines = [f"plan for {self.root}"]
|
|
115
|
+
lines.extend(f" {step.describe()}" for step in self.steps)
|
|
116
|
+
lines.extend(f" remove {path}" for path in self.removals)
|
|
117
|
+
lines.extend(f" collision (foreign file, untouched): {path}" for path in self.collisions)
|
|
118
|
+
lines.extend(f" drifted (user-modified, not overwritten): {path}" for path in self.drifted)
|
|
119
|
+
lines.extend(f" missing (recorded but absent): {path}" for path in self.missing)
|
|
120
|
+
lines.extend(f" notice: {notice}" for notice in self.notices)
|
|
121
|
+
return "\n".join(lines)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _safe_relative(path: str) -> PurePosixPath:
|
|
125
|
+
relative = PurePosixPath(path)
|
|
126
|
+
if relative.is_absolute() or ".." in relative.parts or not str(relative):
|
|
127
|
+
raise SafetyError(f"refusing to plan a path outside the host directory: {path!r}")
|
|
128
|
+
return relative
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def disk_digest(root: Path, path: str) -> str | None:
|
|
132
|
+
target = root / path
|
|
133
|
+
if not target.is_file():
|
|
134
|
+
return None
|
|
135
|
+
return digest(target.read_bytes())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_install_plan(
|
|
139
|
+
root: Path,
|
|
140
|
+
desired: dict[str, bytes],
|
|
141
|
+
ownership: Ownership | None,
|
|
142
|
+
distribution_version: str,
|
|
143
|
+
source: str,
|
|
144
|
+
notices: tuple[str, ...] = (),
|
|
145
|
+
) -> Plan:
|
|
146
|
+
"""Plan the transition of a host directory to ``desired``.
|
|
147
|
+
|
|
148
|
+
Targets that exist without ownership are collisions; owned files whose
|
|
149
|
+
bytes no longer match the record are drift and stay untouched. Clean
|
|
150
|
+
owned files and new paths are (re)written.
|
|
151
|
+
"""
|
|
152
|
+
recorded = ownership.by_path() if ownership else {}
|
|
153
|
+
steps: list[WriteStep] = []
|
|
154
|
+
collisions: list[str] = []
|
|
155
|
+
drifted: list[str] = []
|
|
156
|
+
for path in sorted(desired):
|
|
157
|
+
_safe_relative(path)
|
|
158
|
+
disk = disk_digest(root, path)
|
|
159
|
+
if path not in recorded and disk is not None:
|
|
160
|
+
collisions.append(path)
|
|
161
|
+
continue
|
|
162
|
+
if path in recorded and disk is not None and disk != recorded[path]:
|
|
163
|
+
drifted.append(path)
|
|
164
|
+
continue
|
|
165
|
+
if path in recorded and digest(desired[path]) == recorded[path]:
|
|
166
|
+
continue
|
|
167
|
+
steps.append(WriteStep(path=path, content=desired[path]))
|
|
168
|
+
written = {
|
|
169
|
+
path: digest(content)
|
|
170
|
+
for path, content in desired.items()
|
|
171
|
+
if path not in set(drifted) and path not in set(collisions)
|
|
172
|
+
}
|
|
173
|
+
ownership_after = Ownership(
|
|
174
|
+
distribution_version=distribution_version,
|
|
175
|
+
source=source,
|
|
176
|
+
files=tuple(OwnedFile(path, sha) for path, sha in sorted(written.items())),
|
|
177
|
+
)
|
|
178
|
+
return Plan(
|
|
179
|
+
root=root,
|
|
180
|
+
steps=tuple(steps),
|
|
181
|
+
removals=(),
|
|
182
|
+
collisions=tuple(collisions),
|
|
183
|
+
drifted=tuple(drifted),
|
|
184
|
+
missing=(),
|
|
185
|
+
notices=notices,
|
|
186
|
+
ownership_after=ownership_after,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def build_remove_plan(
|
|
191
|
+
root: Path,
|
|
192
|
+
ownership: Ownership,
|
|
193
|
+
notices: tuple[str, ...] = (),
|
|
194
|
+
) -> Plan:
|
|
195
|
+
"""Plan the removal of exactly the files skillmate owns."""
|
|
196
|
+
removals: list[str] = []
|
|
197
|
+
drifted: list[str] = []
|
|
198
|
+
missing: list[str] = []
|
|
199
|
+
for owned in ownership.files:
|
|
200
|
+
_safe_relative(owned.path)
|
|
201
|
+
disk = disk_digest(root, owned.path)
|
|
202
|
+
if disk is None:
|
|
203
|
+
missing.append(owned.path)
|
|
204
|
+
elif disk != owned.sha256:
|
|
205
|
+
drifted.append(owned.path)
|
|
206
|
+
else:
|
|
207
|
+
removals.append(owned.path)
|
|
208
|
+
return Plan(
|
|
209
|
+
root=root,
|
|
210
|
+
steps=(),
|
|
211
|
+
removals=tuple(removals),
|
|
212
|
+
collisions=(),
|
|
213
|
+
drifted=tuple(drifted),
|
|
214
|
+
missing=tuple(missing),
|
|
215
|
+
notices=notices,
|
|
216
|
+
ownership_after=None,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class ExecutionReport:
|
|
222
|
+
written: tuple[str, ...]
|
|
223
|
+
removed: tuple[str, ...]
|
|
224
|
+
manifest: str
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def execute(plan: Plan, confirmed: bool, confirm_drift: bool = False) -> ExecutionReport:
|
|
228
|
+
"""Apply a plan. Refuses to write anything unconfirmed or conflicted.
|
|
229
|
+
|
|
230
|
+
Drifted files are never written or removed unless ``confirm_drift`` is
|
|
231
|
+
set, matching the contracts that updates leave user drift in place and
|
|
232
|
+
removals of drifted owned files need explicit confirmation.
|
|
233
|
+
"""
|
|
234
|
+
if not confirmed:
|
|
235
|
+
raise SafetyError("refusing to execute an unconfirmed plan; preview it first")
|
|
236
|
+
if plan.collisions:
|
|
237
|
+
raise SafetyError(
|
|
238
|
+
"foreign file collisions must be resolved first: " + ", ".join(plan.collisions)
|
|
239
|
+
)
|
|
240
|
+
removals = list(plan.removals)
|
|
241
|
+
if plan.ownership_after is None and confirm_drift:
|
|
242
|
+
removals.extend(plan.drifted)
|
|
243
|
+
keep_drifted = bool(plan.drifted) and plan.ownership_after is None and not confirm_drift
|
|
244
|
+
|
|
245
|
+
plan.root.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
for step in plan.steps:
|
|
247
|
+
target = plan.root / step.path
|
|
248
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
temporary = target.with_name(target.name + ".skillmate-tmp")
|
|
250
|
+
temporary.write_bytes(step.content)
|
|
251
|
+
os.replace(temporary, target)
|
|
252
|
+
for path in removals:
|
|
253
|
+
target = plan.root / path
|
|
254
|
+
target.unlink(missing_ok=True)
|
|
255
|
+
_prune_empty_parents(plan.root, path)
|
|
256
|
+
|
|
257
|
+
manifest_path = ""
|
|
258
|
+
if plan.ownership_after is not None:
|
|
259
|
+
manifest = plan.root / MANIFEST_RELATIVE
|
|
260
|
+
manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
document = {
|
|
262
|
+
"schema": MANIFEST_SCHEMA,
|
|
263
|
+
"version": plan.ownership_after.distribution_version,
|
|
264
|
+
"source": plan.ownership_after.source,
|
|
265
|
+
"files": [
|
|
266
|
+
{"path": owned.path, "sha256": owned.sha256} for owned in plan.ownership_after.files
|
|
267
|
+
],
|
|
268
|
+
}
|
|
269
|
+
temporary = manifest.with_name("manifest.json.skillmate-tmp")
|
|
270
|
+
temporary.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
|
271
|
+
os.replace(temporary, manifest)
|
|
272
|
+
manifest_path = str(manifest)
|
|
273
|
+
elif keep_drifted:
|
|
274
|
+
# Drifted files remain on disk and stay owned; the manifest stays
|
|
275
|
+
# so a later confirmed removal can still find them.
|
|
276
|
+
manifest_path = str(plan.root / MANIFEST_RELATIVE)
|
|
277
|
+
else:
|
|
278
|
+
manifest = plan.root / MANIFEST_RELATIVE
|
|
279
|
+
if manifest.is_file():
|
|
280
|
+
manifest.unlink()
|
|
281
|
+
_prune_empty_parents(plan.root, MANIFEST_RELATIVE)
|
|
282
|
+
manifest_path = str(manifest)
|
|
283
|
+
return ExecutionReport(
|
|
284
|
+
written=tuple(step.path for step in plan.steps),
|
|
285
|
+
removed=tuple(removals),
|
|
286
|
+
manifest=manifest_path,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _prune_empty_parents(root: Path, relative: str) -> None:
|
|
291
|
+
"""Remove directories the removal emptied, never the host root itself."""
|
|
292
|
+
current = (root / relative).parent
|
|
293
|
+
while current != root and current.is_dir():
|
|
294
|
+
try:
|
|
295
|
+
current.rmdir()
|
|
296
|
+
except OSError:
|
|
297
|
+
return
|
|
298
|
+
current = current.parent
|