rexdeck 0.1.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.
- rexdeck/__init__.py +1 -0
- rexdeck/cli.py +292 -0
- rexdeck/dependencies.py +92 -0
- rexdeck/lifecycle.py +1238 -0
- rexdeck/plugins/files/launch.py +218 -0
- rexdeck/plugins/files/main.lua +92 -0
- rexdeck/settings.py +188 -0
- rexdeck-0.1.0.dist-info/METADATA +127 -0
- rexdeck-0.1.0.dist-info/RECORD +12 -0
- rexdeck-0.1.0.dist-info/WHEEL +4 -0
- rexdeck-0.1.0.dist-info/entry_points.txt +2 -0
- rexdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
rexdeck/lifecycle.py
ADDED
|
@@ -0,0 +1,1238 @@
|
|
|
1
|
+
"""Explicit, removable native Rex action setup; no managed processes or runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import selectors
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from types import FrameType
|
|
23
|
+
from typing import NoReturn, cast
|
|
24
|
+
from urllib.parse import quote, unquote
|
|
25
|
+
|
|
26
|
+
_BEGIN = b"-- BEGIN REXDECK (owned native Files loader)\n"
|
|
27
|
+
_END = b"-- END REXDECK\n"
|
|
28
|
+
_MAX_READ = 2 * 1024 * 1024
|
|
29
|
+
_COMMAND_WAIT = 8.0
|
|
30
|
+
PLUGINS = ("files", "git")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class QualifiedTool:
|
|
35
|
+
"""Freeze a logical executable binding and its followed target's identity."""
|
|
36
|
+
|
|
37
|
+
path: str
|
|
38
|
+
identity: tuple[int, int, int, int, int, int, int]
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def capture(cls, path: str | Path) -> QualifiedTool:
|
|
42
|
+
"""Record target metadata without replacing the symlink-preserving binding."""
|
|
43
|
+
binding = str(Path(path).absolute())
|
|
44
|
+
try:
|
|
45
|
+
current = Path(binding).stat()
|
|
46
|
+
if not stat.S_ISREG(current.st_mode) or not os.access(binding, os.X_OK):
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"Qualified tool is not an executable file: {binding}"
|
|
49
|
+
)
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Cannot capture executable identity for {binding}: {exc}"
|
|
53
|
+
) from exc
|
|
54
|
+
return cls(
|
|
55
|
+
binding,
|
|
56
|
+
(
|
|
57
|
+
current.st_dev,
|
|
58
|
+
current.st_ino,
|
|
59
|
+
current.st_mode,
|
|
60
|
+
current.st_size,
|
|
61
|
+
current.st_mtime_ns,
|
|
62
|
+
current.st_ctime_ns,
|
|
63
|
+
current.st_uid,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def require_current(self) -> None:
|
|
68
|
+
"""Reject replacement, retargeting or mutation since version qualification."""
|
|
69
|
+
try:
|
|
70
|
+
current = self.capture(self.path)
|
|
71
|
+
except RuntimeError as exc:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"Executable changed after qualification ({self.path}): {exc}"
|
|
74
|
+
) from exc
|
|
75
|
+
if current != self:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"Executable changed after qualification ({self.path}); "
|
|
78
|
+
"rerun rexdeck enable for the selected integration"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Setup:
|
|
84
|
+
project: Path
|
|
85
|
+
workspace: Path
|
|
86
|
+
config: Path
|
|
87
|
+
data_root: Path
|
|
88
|
+
server: str
|
|
89
|
+
real_server: bool = False
|
|
90
|
+
plugin: str = "files"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class _Paths:
|
|
95
|
+
project: Path
|
|
96
|
+
workspace: Path
|
|
97
|
+
selected: Path
|
|
98
|
+
config: Path
|
|
99
|
+
data_root: Path
|
|
100
|
+
module: Path
|
|
101
|
+
receipt: Path
|
|
102
|
+
plugin: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class _File:
|
|
107
|
+
data: bytes | None
|
|
108
|
+
mode: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class _Change:
|
|
113
|
+
path: Path
|
|
114
|
+
before: _File
|
|
115
|
+
after: bytes | None
|
|
116
|
+
loader: bool = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class _Result:
|
|
121
|
+
code: int
|
|
122
|
+
output: bytes
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _socket_path(endpoint: str) -> Path:
|
|
126
|
+
if not endpoint.startswith("unix://"):
|
|
127
|
+
raise RuntimeError("An explicit unix:// Rex socket endpoint is required")
|
|
128
|
+
try:
|
|
129
|
+
decoded = unquote(endpoint[len("unix://") :], errors="strict")
|
|
130
|
+
path = Path(decoded)
|
|
131
|
+
if not path.is_absolute() or "\x00" in decoded:
|
|
132
|
+
raise RuntimeError("Rex Unix socket endpoint must name an absolute path")
|
|
133
|
+
return path.resolve()
|
|
134
|
+
except (OSError, UnicodeError) as exc:
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"Cannot resolve the selected Rex socket endpoint: {exc}"
|
|
137
|
+
) from exc
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _canonical_server(endpoint: str) -> str:
|
|
141
|
+
return "unix://" + quote(str(_socket_path(endpoint)), safe="/")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _validate_data_root(root: Path, plugin: str | None) -> None:
|
|
145
|
+
"""Reject redirection of shared parents and any selected deployment directory."""
|
|
146
|
+
try:
|
|
147
|
+
if (
|
|
148
|
+
not root.is_absolute()
|
|
149
|
+
or ".." in root.parts
|
|
150
|
+
or "\x00" in str(root)
|
|
151
|
+
or root.resolve() != root
|
|
152
|
+
):
|
|
153
|
+
raise RuntimeError("Files data root must remain a resolved absolute path")
|
|
154
|
+
paths: tuple[Path, ...] = (root, root / "plugins")
|
|
155
|
+
if plugin is not None:
|
|
156
|
+
paths += (root / "plugins" / plugin,)
|
|
157
|
+
for path in paths:
|
|
158
|
+
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
|
159
|
+
raise RuntimeError(f"Files deployment directory is redirected: {path}")
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
raise RuntimeError(f"Cannot validate Files data directory: {exc}") from exc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _paths(setup: Setup) -> _Paths:
|
|
165
|
+
"""Resolve the selected target without consulting unused sibling deployments."""
|
|
166
|
+
if setup.plugin not in PLUGINS:
|
|
167
|
+
raise RuntimeError(f"Unknown integration: {setup.plugin}")
|
|
168
|
+
if not setup.server.strip() or setup.server != setup.server.strip():
|
|
169
|
+
raise RuntimeError("An explicit nonempty Rex server endpoint is required")
|
|
170
|
+
socket = _socket_path(setup.server)
|
|
171
|
+
if not setup.real_server and not any(
|
|
172
|
+
socket.is_relative_to(root.resolve())
|
|
173
|
+
for root in (Path("/tmp"), Path("/private/tmp"))
|
|
174
|
+
):
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
"Isolated endpoints must be Unix sockets under /tmp or /private/tmp; "
|
|
177
|
+
"use --real-server only for an explicitly approved real-server application"
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
selected = setup.config.absolute()
|
|
181
|
+
config = selected.resolve(strict=True)
|
|
182
|
+
if not config.is_file():
|
|
183
|
+
raise RuntimeError(f"Selected Rex config is not a regular file: {config}")
|
|
184
|
+
project = setup.project.resolve(strict=True)
|
|
185
|
+
workspace = setup.workspace.resolve()
|
|
186
|
+
if setup.plugin != "files":
|
|
187
|
+
workspace = workspace / setup.plugin
|
|
188
|
+
if workspace.is_symlink():
|
|
189
|
+
raise RuntimeError(f"Integration workspace is redirected: {workspace}")
|
|
190
|
+
if workspace.exists() and not workspace.is_dir():
|
|
191
|
+
raise RuntimeError(f"Setup workspace must be a directory: {workspace}")
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
raise RuntimeError(f"Cannot resolve the explicit setup paths: {exc}") from exc
|
|
194
|
+
_validate_data_root(setup.data_root, setup.plugin)
|
|
195
|
+
return _Paths(
|
|
196
|
+
project,
|
|
197
|
+
workspace,
|
|
198
|
+
selected,
|
|
199
|
+
config,
|
|
200
|
+
setup.data_root,
|
|
201
|
+
setup.data_root / f"plugins/{setup.plugin}/rexdeck_{setup.plugin}.lua",
|
|
202
|
+
workspace / "setup.json",
|
|
203
|
+
setup.plugin,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _env(setup: Setup) -> dict[str, str]:
|
|
208
|
+
result = {
|
|
209
|
+
k: v
|
|
210
|
+
for k, v in os.environ.items()
|
|
211
|
+
if k not in {"REX_SESSION", "REX_BLOCK", "REX_CLIENT", "REX_SERVER"}
|
|
212
|
+
}
|
|
213
|
+
result["REX_CONFIG"] = str(setup.config.absolute())
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _executable(name: str) -> str:
|
|
218
|
+
"""Resolve trusted absolute PATH entries and explain external prerequisites."""
|
|
219
|
+
search_path = os.pathsep.join(
|
|
220
|
+
entry
|
|
221
|
+
for entry in os.environ.get("PATH", os.defpath).split(os.pathsep)
|
|
222
|
+
if Path(entry).is_absolute()
|
|
223
|
+
)
|
|
224
|
+
found = shutil.which(name, path=search_path) if search_path else None
|
|
225
|
+
if found is None:
|
|
226
|
+
if name == "rex":
|
|
227
|
+
raise RuntimeError(
|
|
228
|
+
"Rex is an externally installed prerequisite and was not found on PATH; "
|
|
229
|
+
"install Rex using its supported distribution, make rex available on PATH, "
|
|
230
|
+
"start its server, then rerun rexdeck setup. RexDeck never installs Rex"
|
|
231
|
+
)
|
|
232
|
+
raise RuntimeError(
|
|
233
|
+
f"Required executable not found: {name}; install it before applying or starting"
|
|
234
|
+
)
|
|
235
|
+
return str(Path(found).absolute())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _tools(tool: str = "yazi") -> dict[str, str]:
|
|
239
|
+
"""Bind the current interpreter, Rex and selected upstream tool to paths."""
|
|
240
|
+
python = Path(sys.executable).absolute()
|
|
241
|
+
if not python.is_file() or not os.access(python, os.X_OK):
|
|
242
|
+
raise RuntimeError(f"Current Python interpreter is not executable: {python}")
|
|
243
|
+
return {
|
|
244
|
+
"python": str(python),
|
|
245
|
+
"rex": _executable("rex"),
|
|
246
|
+
tool: _executable(tool),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _selected_tools(setup: Setup) -> dict[str, str]:
|
|
251
|
+
"""Resolve only the executables required by the selected integration."""
|
|
252
|
+
return _tools() if setup.plugin == "files" else _tools("lazygit")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _require_qualified_tool(
|
|
256
|
+
setup: Setup, tools: dict[str, str], expected_tool: QualifiedTool | None
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Require the persisted binding and followed target to retain qualification."""
|
|
259
|
+
tool = "yazi" if setup.plugin == "files" else "lazygit"
|
|
260
|
+
if expected_tool is None:
|
|
261
|
+
return
|
|
262
|
+
if tools[tool] != expected_tool.path:
|
|
263
|
+
raise RuntimeError(
|
|
264
|
+
f"{tool} executable changed after qualification "
|
|
265
|
+
f"({expected_tool.path} -> {tools[tool]}); "
|
|
266
|
+
f"rerun rexdeck enable {setup.plugin}"
|
|
267
|
+
)
|
|
268
|
+
expected_tool.require_current()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _run(
|
|
272
|
+
argv: list[str],
|
|
273
|
+
env: dict[str, str],
|
|
274
|
+
*,
|
|
275
|
+
timeout: float = _COMMAND_WAIT,
|
|
276
|
+
) -> _Result:
|
|
277
|
+
"""Bound merged diagnostic output and child lifetime."""
|
|
278
|
+
child: subprocess.Popen[bytes] | None = None
|
|
279
|
+
try:
|
|
280
|
+
with _defer_cancellation():
|
|
281
|
+
child = subprocess.Popen(
|
|
282
|
+
argv,
|
|
283
|
+
env=env,
|
|
284
|
+
stdin=subprocess.DEVNULL,
|
|
285
|
+
stdout=subprocess.PIPE,
|
|
286
|
+
stderr=subprocess.STDOUT,
|
|
287
|
+
)
|
|
288
|
+
assert child.stdout is not None
|
|
289
|
+
output = bytearray()
|
|
290
|
+
deadline = time.monotonic() + timeout
|
|
291
|
+
with selectors.DefaultSelector() as selector:
|
|
292
|
+
selector.register(child.stdout, selectors.EVENT_READ)
|
|
293
|
+
while selector.get_map():
|
|
294
|
+
remaining = deadline - time.monotonic()
|
|
295
|
+
if remaining <= 0:
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
f"Command timed out: {argv[0]} {' '.join(argv[1:])}"
|
|
298
|
+
)
|
|
299
|
+
for key, _ in selector.select(remaining):
|
|
300
|
+
chunk = os.read(key.fd, min(65536, _MAX_READ + 1 - len(output)))
|
|
301
|
+
if not chunk:
|
|
302
|
+
selector.unregister(child.stdout)
|
|
303
|
+
else:
|
|
304
|
+
output.extend(chunk)
|
|
305
|
+
if len(output) > _MAX_READ:
|
|
306
|
+
raise RuntimeError(
|
|
307
|
+
f"Command output exceeds {_MAX_READ} bytes: {argv[0]}"
|
|
308
|
+
)
|
|
309
|
+
return _Result(
|
|
310
|
+
child.wait(timeout=max(0.01, deadline - time.monotonic())), bytes(output)
|
|
311
|
+
)
|
|
312
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired, KeyboardInterrupt) as exc:
|
|
313
|
+
with _defer_cancellation():
|
|
314
|
+
if child is not None:
|
|
315
|
+
if child.poll() is None:
|
|
316
|
+
child.kill()
|
|
317
|
+
child.wait(timeout=2)
|
|
318
|
+
if isinstance(exc, (RuntimeError, KeyboardInterrupt)):
|
|
319
|
+
raise
|
|
320
|
+
raise RuntimeError(f"Command failed: {argv[0]}: {exc}") from exc
|
|
321
|
+
finally:
|
|
322
|
+
if child is not None and child.stdout is not None:
|
|
323
|
+
child.stdout.close()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _object(value: object, context: str) -> dict[str, object]:
|
|
327
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
328
|
+
raise RuntimeError(f"Expected a JSON object in {context}")
|
|
329
|
+
return cast(dict[str, object], value)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _list(value: object, context: str) -> list[object]:
|
|
333
|
+
if not isinstance(value, list):
|
|
334
|
+
raise RuntimeError(f"Expected a JSON list in {context}")
|
|
335
|
+
return cast(list[object], value)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _decode(data: bytes, context: str) -> dict[str, object]:
|
|
339
|
+
try:
|
|
340
|
+
value: object = json.loads(data)
|
|
341
|
+
except (ValueError, UnicodeError) as exc:
|
|
342
|
+
raise RuntimeError(f"Invalid JSON in {context}: {exc}") from exc
|
|
343
|
+
return _object(value, context)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _json(value: object) -> bytes:
|
|
347
|
+
return (
|
|
348
|
+
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
349
|
+
).encode()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _rex(setup: Setup, *args: str) -> dict[str, object]:
|
|
353
|
+
result = _run(
|
|
354
|
+
[
|
|
355
|
+
_executable("rex"),
|
|
356
|
+
"-S",
|
|
357
|
+
setup.server,
|
|
358
|
+
"--autostart=false",
|
|
359
|
+
"--timeout",
|
|
360
|
+
"3s",
|
|
361
|
+
*args,
|
|
362
|
+
],
|
|
363
|
+
_env(setup),
|
|
364
|
+
)
|
|
365
|
+
if result.code != 0:
|
|
366
|
+
raise RuntimeError(
|
|
367
|
+
f"rex {' '.join(args)} failed ({result.code}): "
|
|
368
|
+
f"{result.output.decode(errors='replace').strip()}"
|
|
369
|
+
)
|
|
370
|
+
return _decode(result.output, f"rex {' '.join(args)}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _server_status(setup: Setup, *, selected: bool) -> dict[str, object] | None:
|
|
374
|
+
argv = [_executable("rex"), "--autostart=false", "--timeout", "3s"]
|
|
375
|
+
if selected:
|
|
376
|
+
argv += ["-S", setup.server]
|
|
377
|
+
result = _run([*argv, "server", "status", "--json"], _env(setup))
|
|
378
|
+
if result.code != 0:
|
|
379
|
+
detail = result.output.decode(errors="replace")
|
|
380
|
+
if not selected and "rex server is not running" in detail:
|
|
381
|
+
return None
|
|
382
|
+
raise RuntimeError(
|
|
383
|
+
f"Cannot prove selected/default Rex endpoint isolation: {detail.strip()}"
|
|
384
|
+
)
|
|
385
|
+
value = _decode(result.output, "Rex server status")
|
|
386
|
+
if any(
|
|
387
|
+
not isinstance(value.get(key), str) or not value[key]
|
|
388
|
+
for key in ("endpoint", "instance_id")
|
|
389
|
+
):
|
|
390
|
+
raise RuntimeError("Rex server status lacks endpoint/instance identity")
|
|
391
|
+
return value
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _is_real_endpoint(setup: Setup) -> bool:
|
|
395
|
+
selected = _socket_path(setup.server)
|
|
396
|
+
known = [
|
|
397
|
+
Path.home() / "Library/Application Support/rex/server.sock",
|
|
398
|
+
Path("/run/user") / str(os.getuid()) / "rex/server.sock",
|
|
399
|
+
]
|
|
400
|
+
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
401
|
+
if xdg and Path(xdg).is_absolute():
|
|
402
|
+
known.append(Path(xdg) / "rex/server.sock")
|
|
403
|
+
is_real = selected in [path.resolve() for path in known]
|
|
404
|
+
is_real = is_real or str(selected).endswith(
|
|
405
|
+
"/Library/Application Support/rex/server.sock"
|
|
406
|
+
)
|
|
407
|
+
is_real = is_real or (
|
|
408
|
+
selected.name == "server.sock"
|
|
409
|
+
and selected.parent.name == "rex"
|
|
410
|
+
and selected.parent.parent.parent == Path("/run/user")
|
|
411
|
+
and selected.parent.parent.name.isdecimal()
|
|
412
|
+
)
|
|
413
|
+
if not is_real:
|
|
414
|
+
default = _server_status(setup, selected=False)
|
|
415
|
+
current = _server_status(setup, selected=True)
|
|
416
|
+
assert current is not None
|
|
417
|
+
if _socket_path(cast(str, current["endpoint"])) != selected:
|
|
418
|
+
raise RuntimeError(
|
|
419
|
+
"Selected server answered with a different socket endpoint"
|
|
420
|
+
)
|
|
421
|
+
if default is not None:
|
|
422
|
+
is_real = (
|
|
423
|
+
_socket_path(cast(str, default["endpoint"])) == selected
|
|
424
|
+
or default["instance_id"] == current["instance_id"]
|
|
425
|
+
)
|
|
426
|
+
return is_real
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _endpoint_policy(setup: Setup) -> None:
|
|
430
|
+
is_real = _is_real_endpoint(setup)
|
|
431
|
+
if is_real and not setup.real_server:
|
|
432
|
+
raise RuntimeError(
|
|
433
|
+
"Selected endpoint is the real Rex server; explicit --real-server approval is required"
|
|
434
|
+
)
|
|
435
|
+
keymap = _rex(setup, "api", "call", "client.keymap", "{}")
|
|
436
|
+
config = keymap.get("config")
|
|
437
|
+
path = config.get("path") if isinstance(config, dict) else None
|
|
438
|
+
if not isinstance(path, str) or not Path(path).is_absolute() or "\x00" in path:
|
|
439
|
+
raise RuntimeError("Running Rex server did not identify its config path")
|
|
440
|
+
if Path(path).resolve(strict=True) != setup.config.resolve(strict=True):
|
|
441
|
+
raise RuntimeError(
|
|
442
|
+
f"Selected Rex config does not match the running server config: {path}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _check(setup: Setup) -> None:
|
|
447
|
+
result = _rex(setup, "config", "check", "--json")
|
|
448
|
+
if result.get("diagnostics"):
|
|
449
|
+
raise RuntimeError(f"Selected Rex config was rejected: {result['diagnostics']}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _reload(setup: Setup) -> None:
|
|
453
|
+
result = _rex(setup, "config", "reload", "--json")
|
|
454
|
+
if result.get("diagnostics"):
|
|
455
|
+
raise RuntimeError(
|
|
456
|
+
f"Selected Rex config reload was rejected: {result['diagnostics']}"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _file(path: Path) -> _File:
|
|
461
|
+
try:
|
|
462
|
+
info = path.lstat()
|
|
463
|
+
if not stat.S_ISREG(info.st_mode):
|
|
464
|
+
raise RuntimeError(
|
|
465
|
+
f"Refusing nonregular or symlinked owned artifact: {path}"
|
|
466
|
+
)
|
|
467
|
+
with path.open("rb") as stream:
|
|
468
|
+
data = stream.read(_MAX_READ + 1)
|
|
469
|
+
if len(data) > _MAX_READ:
|
|
470
|
+
raise RuntimeError(f"Owned file exceeds {_MAX_READ} bytes: {path}")
|
|
471
|
+
return _File(data, stat.S_IMODE(info.st_mode))
|
|
472
|
+
except FileNotFoundError:
|
|
473
|
+
return _File(None, 0o600)
|
|
474
|
+
except OSError as exc:
|
|
475
|
+
raise RuntimeError(f"Cannot read {path}: {exc}") from exc
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _replace(
|
|
479
|
+
path: Path, before: _File, after: bytes | None, *, mode: int | None = None
|
|
480
|
+
) -> None:
|
|
481
|
+
if _file(path) != before:
|
|
482
|
+
raise RuntimeError(
|
|
483
|
+
f"Concurrent edit or mode change at {path}; nothing overwritten"
|
|
484
|
+
)
|
|
485
|
+
if after is None:
|
|
486
|
+
path.unlink()
|
|
487
|
+
return
|
|
488
|
+
temporary: Path | None = None
|
|
489
|
+
try:
|
|
490
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
|
|
491
|
+
temporary = Path(stream.name)
|
|
492
|
+
stream.write(after)
|
|
493
|
+
stream.flush()
|
|
494
|
+
os.fsync(stream.fileno())
|
|
495
|
+
os.fchmod(stream.fileno(), before.mode if mode is None else mode)
|
|
496
|
+
if _file(path) != before:
|
|
497
|
+
raise RuntimeError(f"Concurrent edit at {path}; nothing overwritten")
|
|
498
|
+
os.replace(temporary, path)
|
|
499
|
+
except OSError as exc:
|
|
500
|
+
raise RuntimeError(f"Cannot atomically write {path}: {exc}") from exc
|
|
501
|
+
finally:
|
|
502
|
+
if temporary is not None:
|
|
503
|
+
temporary.unlink(missing_ok=True)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _digest(data: bytes | None) -> str:
|
|
507
|
+
return hashlib.sha256(data).hexdigest() if data is not None else ""
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _lua(value: str) -> str:
|
|
511
|
+
"""Quote UTF-8 bytes using Lua escapes, never JSON-only Unicode escapes."""
|
|
512
|
+
return (
|
|
513
|
+
'"'
|
|
514
|
+
+ "".join(
|
|
515
|
+
chr(byte) if 32 <= byte < 127 and byte not in (34, 92) else f"\\{byte:03d}"
|
|
516
|
+
for byte in value.encode("utf-8")
|
|
517
|
+
)
|
|
518
|
+
+ '"'
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _loader(paths: _Paths) -> bytes:
|
|
523
|
+
"""Build the tracked loader while preserving native Files loader identity."""
|
|
524
|
+
# Rex tracks required modules. Restore both its cache and the user's search path.
|
|
525
|
+
if ";" in str(paths.module.parent) or "?" in str(paths.module.parent):
|
|
526
|
+
raise RuntimeError(
|
|
527
|
+
"Rex cannot track a module in a directory containing ';' or '?'"
|
|
528
|
+
)
|
|
529
|
+
search = _lua(str(paths.module.parent / "?.lua") + ";")
|
|
530
|
+
module = _lua(paths.module.stem)
|
|
531
|
+
begin = (
|
|
532
|
+
_BEGIN
|
|
533
|
+
if paths.plugin == "files"
|
|
534
|
+
else b"-- BEGIN REXDECK (owned native Git loader)\n"
|
|
535
|
+
)
|
|
536
|
+
end = _END if paths.plugin == "files" else b"-- END REXDECK git\n"
|
|
537
|
+
return (
|
|
538
|
+
begin
|
|
539
|
+
+ (
|
|
540
|
+
f"do local previous, cached = package.path, package.loaded[{module}]; "
|
|
541
|
+
f"package.path = {search} .. previous; package.loaded[{module}] = nil; "
|
|
542
|
+
f"local ok, err = pcall(require, {module}); "
|
|
543
|
+
f"package.loaded[{module}] = cached; package.path = previous; "
|
|
544
|
+
f"if not ok then error('RexDeck {'Files' if paths.plugin == 'files' else 'Git'} unavailable: ' .. tostring(err)) end end\n"
|
|
545
|
+
).encode()
|
|
546
|
+
+ end
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _expect_action(setup: Setup, enabled: bool) -> None:
|
|
551
|
+
"""Require exact owned action identity or verified absence after reload."""
|
|
552
|
+
title = "Files" if setup.plugin == "files" else "Git"
|
|
553
|
+
listing = _rex(setup, "actions", "--json")
|
|
554
|
+
actions = [
|
|
555
|
+
_object(row, "Rex action")
|
|
556
|
+
for row in _list(listing.get("actions"), "Rex actions")
|
|
557
|
+
]
|
|
558
|
+
matches = [row for row in actions if row.get("name") == f"rexdeck_{setup.plugin}"]
|
|
559
|
+
if not enabled:
|
|
560
|
+
if matches:
|
|
561
|
+
raise RuntimeError(
|
|
562
|
+
f"Native {title} action remains registered or is foreign"
|
|
563
|
+
)
|
|
564
|
+
return
|
|
565
|
+
if len(matches) != 1:
|
|
566
|
+
raise RuntimeError(
|
|
567
|
+
f"Native {title} action is missing or ambiguous after config reload"
|
|
568
|
+
)
|
|
569
|
+
action = matches[0]
|
|
570
|
+
if (
|
|
571
|
+
action.get("kind") != "lua"
|
|
572
|
+
or action.get("title") != f"RexDeck {title}"
|
|
573
|
+
or action.get("category") != "RexDeck"
|
|
574
|
+
):
|
|
575
|
+
raise RuntimeError(
|
|
576
|
+
f"{title} action identity is not the owned native Lua action"
|
|
577
|
+
)
|
|
578
|
+
source = action.get("source")
|
|
579
|
+
if not isinstance(source, str):
|
|
580
|
+
raise RuntimeError(f"{title} action has no native source identity")
|
|
581
|
+
filename, separator, line = source.rpartition(":")
|
|
582
|
+
paths = _paths(setup)
|
|
583
|
+
owned_sources = {str(paths.module)} | {
|
|
584
|
+
str(paths.module.relative_to(parent))
|
|
585
|
+
for parent in (paths.selected.parent, paths.config.parent)
|
|
586
|
+
if paths.module.is_relative_to(parent)
|
|
587
|
+
}
|
|
588
|
+
if not separator or not line.isdecimal() or filename not in owned_sources:
|
|
589
|
+
raise RuntimeError(
|
|
590
|
+
f"{title} action is registered from an unexpected config source"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _without_loader(
|
|
595
|
+
data: bytes, loader: bytes, *, remove_separator: bool = False
|
|
596
|
+
) -> bytes:
|
|
597
|
+
"""Remove one exact loader, preserving foreign content and separator ownership."""
|
|
598
|
+
lines = loader.splitlines(keepends=True)
|
|
599
|
+
if (
|
|
600
|
+
data.count(lines[0]) != 1
|
|
601
|
+
or data.count(lines[-1]) != 1
|
|
602
|
+
or data.count(loader) != 1
|
|
603
|
+
):
|
|
604
|
+
raise RuntimeError(
|
|
605
|
+
"RexDeck loader changed or is ambiguous; preserve it for manual reconciliation"
|
|
606
|
+
)
|
|
607
|
+
prefix, _, tail = data.partition(loader)
|
|
608
|
+
if remove_separator and not tail and prefix.endswith(b"\n"):
|
|
609
|
+
prefix = prefix[:-1]
|
|
610
|
+
return prefix + tail
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _sources(paths: _Paths) -> tuple[bytes, bytes]:
|
|
614
|
+
source = _file(paths.project / "plugins/files/main.lua").data
|
|
615
|
+
launcher = _file(paths.project / "plugins/files/launch.py").data
|
|
616
|
+
if not source or not launcher:
|
|
617
|
+
raise RuntimeError("Canonical native Files main.lua and launch.py are required")
|
|
618
|
+
try:
|
|
619
|
+
source.decode("utf-8")
|
|
620
|
+
launcher.decode("utf-8")
|
|
621
|
+
except UnicodeError as exc:
|
|
622
|
+
raise RuntimeError("Native Files module and launcher must be UTF-8") from exc
|
|
623
|
+
return source, launcher
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _module(
|
|
627
|
+
setup: Setup, source: bytes, launcher: bytes, tools: dict[str, str], enabled: bool
|
|
628
|
+
) -> bytes:
|
|
629
|
+
"""Embed canonical action and launcher code for one selected integration."""
|
|
630
|
+
opts = tools | {
|
|
631
|
+
"server": setup.server,
|
|
632
|
+
"launcher_code": launcher.decode("utf-8"),
|
|
633
|
+
"plugin": setup.plugin,
|
|
634
|
+
}
|
|
635
|
+
quoted = ", ".join(f"{key} = {_lua(value)}" for key, value in sorted(opts.items()))
|
|
636
|
+
return (
|
|
637
|
+
b"-- Generated from canonical plugins/files/main.lua by RexDeck.\n"
|
|
638
|
+
b"local install = (function()\n"
|
|
639
|
+
+ source
|
|
640
|
+
+ b"\nend)()\n"
|
|
641
|
+
+ f"if {'true' if enabled else 'false'} then install({{{quoted}}}) end\n".encode()
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _receipt(paths: _Paths, setup: Setup) -> dict[str, object] | None:
|
|
646
|
+
"""Read a receipt only if its schema, target and ownership fields are valid."""
|
|
647
|
+
snapshot = _file(paths.receipt)
|
|
648
|
+
if snapshot.data is None:
|
|
649
|
+
return None
|
|
650
|
+
result = _decode(snapshot.data, str(paths.receipt))
|
|
651
|
+
expected: dict[str, object] = {
|
|
652
|
+
"version": 3,
|
|
653
|
+
"kind": f"native-{setup.plugin}",
|
|
654
|
+
"workspace": str(paths.workspace),
|
|
655
|
+
"data_root": str(paths.data_root),
|
|
656
|
+
"config": str(paths.selected),
|
|
657
|
+
"config_target": str(paths.config),
|
|
658
|
+
"server": setup.server,
|
|
659
|
+
"real_server": setup.real_server,
|
|
660
|
+
}
|
|
661
|
+
if any(result.get(key) != value for key, value in expected.items()):
|
|
662
|
+
raise RuntimeError(
|
|
663
|
+
"Unknown/old receipt or different setup identity; use a fresh native workspace"
|
|
664
|
+
)
|
|
665
|
+
# Package location is provenance, not ownership: upgrades may remove the old root.
|
|
666
|
+
project = result.get("project")
|
|
667
|
+
if (
|
|
668
|
+
not isinstance(project, str)
|
|
669
|
+
or not Path(project).is_absolute()
|
|
670
|
+
or "\x00" in project
|
|
671
|
+
):
|
|
672
|
+
raise RuntimeError("Setup receipt has malformed project provenance")
|
|
673
|
+
for key in ("applied", "enabled"):
|
|
674
|
+
if not isinstance(result.get(key), bool):
|
|
675
|
+
raise RuntimeError(f"Setup receipt has no explicit {key} state")
|
|
676
|
+
if result["enabled"] and not result["applied"]:
|
|
677
|
+
raise RuntimeError("Unapplied receipt cannot enable Files")
|
|
678
|
+
for key in ("module_sha256", "source_sha256", "launcher_sha256"):
|
|
679
|
+
value = result.get(key)
|
|
680
|
+
if (
|
|
681
|
+
not isinstance(value, str)
|
|
682
|
+
or len(value) != 64
|
|
683
|
+
or any(c not in "0123456789abcdef" for c in value)
|
|
684
|
+
):
|
|
685
|
+
raise RuntimeError(f"Setup receipt has malformed {key}")
|
|
686
|
+
mode = result.get("module_mode")
|
|
687
|
+
if not isinstance(mode, int) or isinstance(mode, bool) or not 0 <= mode <= 0o7777:
|
|
688
|
+
raise RuntimeError("Setup receipt has malformed module_mode")
|
|
689
|
+
if result.get("loader_separator") not in ("", "\n"):
|
|
690
|
+
raise RuntimeError("Setup receipt has malformed loader_separator")
|
|
691
|
+
tools = _object(result.get("tools"), "receipt tools")
|
|
692
|
+
tool = "yazi" if setup.plugin == "files" else "lazygit"
|
|
693
|
+
if set(tools) != {"python", "rex", tool} or any(
|
|
694
|
+
not isinstance(value, str) or not Path(value).is_absolute() or "\x00" in value
|
|
695
|
+
for value in tools.values()
|
|
696
|
+
):
|
|
697
|
+
raise RuntimeError("Setup receipt has malformed tools")
|
|
698
|
+
conflicts = _list(result.get("conflicts"), "receipt conflicts")
|
|
699
|
+
if any(not isinstance(item, str) for item in conflicts):
|
|
700
|
+
raise RuntimeError("Setup receipt has malformed conflicts")
|
|
701
|
+
return result
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _owned_config(paths: _Paths, receipt: dict[str, object]) -> None:
|
|
705
|
+
module = _file(paths.module)
|
|
706
|
+
if (
|
|
707
|
+
_digest(module.data) != receipt["module_sha256"]
|
|
708
|
+
or module.mode != receipt["module_mode"]
|
|
709
|
+
):
|
|
710
|
+
raise RuntimeError(f"Owned native module changed or is missing: {paths.module}")
|
|
711
|
+
data = _file(paths.config).data
|
|
712
|
+
if data is None:
|
|
713
|
+
raise RuntimeError(f"Selected config disappeared: {paths.config}")
|
|
714
|
+
_without_loader(data, _loader(paths))
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _current_bundle(setup: Setup, paths: _Paths, receipt: dict[str, object]) -> None:
|
|
718
|
+
"""Require owned artifacts to match the current package and executable paths."""
|
|
719
|
+
_owned_config(paths, receipt)
|
|
720
|
+
source, launcher = _sources(paths)
|
|
721
|
+
tools = _selected_tools(setup)
|
|
722
|
+
if (
|
|
723
|
+
_digest(source) != receipt["source_sha256"]
|
|
724
|
+
or _digest(launcher) != receipt["launcher_sha256"]
|
|
725
|
+
or tools != receipt["tools"]
|
|
726
|
+
or _digest(_module(setup, source, launcher, tools, bool(receipt["enabled"])))
|
|
727
|
+
!= receipt["module_sha256"]
|
|
728
|
+
):
|
|
729
|
+
raise RuntimeError(
|
|
730
|
+
f"Canonical {setup.plugin} source, launcher or tool paths changed; "
|
|
731
|
+
f"run rexdeck enable {setup.plugin} to refresh the owned integration"
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@contextmanager
|
|
736
|
+
def _defer_cancellation() -> Iterator[None]:
|
|
737
|
+
"""Finish an ownership handoff or recovery before delivering cancellation."""
|
|
738
|
+
if threading.current_thread() is not threading.main_thread():
|
|
739
|
+
yield
|
|
740
|
+
return
|
|
741
|
+
signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
|
742
|
+
previous = {signum: signal.getsignal(signum) for signum in signals}
|
|
743
|
+
interrupted: int | None = None
|
|
744
|
+
|
|
745
|
+
def defer(signum: int, _frame: FrameType | None) -> None:
|
|
746
|
+
nonlocal interrupted
|
|
747
|
+
if interrupted is None and previous[signum] != signal.SIG_IGN:
|
|
748
|
+
interrupted = signum
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
for signum in signals:
|
|
752
|
+
signal.signal(signum, defer)
|
|
753
|
+
yield
|
|
754
|
+
finally:
|
|
755
|
+
for signum, handler in previous.items():
|
|
756
|
+
signal.signal(signum, handler)
|
|
757
|
+
if interrupted is not None:
|
|
758
|
+
handler = previous[interrupted]
|
|
759
|
+
if callable(handler):
|
|
760
|
+
handler(interrupted, None)
|
|
761
|
+
elif handler == signal.SIG_DFL:
|
|
762
|
+
raise KeyboardInterrupt(f"Received signal {interrupted}")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _rollback(changes: list[_Change], data_root: Path, plugin: str) -> list[str]:
|
|
766
|
+
"""Restore owned preimages without overwriting concurrent edits."""
|
|
767
|
+
conflicts: list[str] = []
|
|
768
|
+
for change in reversed(changes):
|
|
769
|
+
try:
|
|
770
|
+
if change.path.is_relative_to(data_root):
|
|
771
|
+
_validate_data_root(data_root, plugin)
|
|
772
|
+
current = _file(change.path)
|
|
773
|
+
if current == change.before:
|
|
774
|
+
continue
|
|
775
|
+
if change.loader and current.data is not None and change.after is not None:
|
|
776
|
+
before_data = change.before.data or b""
|
|
777
|
+
if change.after.startswith(before_data):
|
|
778
|
+
added = change.after[len(before_data) :]
|
|
779
|
+
restored = _without_loader(
|
|
780
|
+
current.data,
|
|
781
|
+
added.removeprefix(b"\n"),
|
|
782
|
+
remove_separator=added.startswith(b"\n"),
|
|
783
|
+
)
|
|
784
|
+
elif current.data.startswith(change.after):
|
|
785
|
+
restored = before_data + current.data[len(change.after) :]
|
|
786
|
+
else:
|
|
787
|
+
raise RuntimeError(
|
|
788
|
+
f"Rollback preserves concurrent config edits: {change.path}"
|
|
789
|
+
)
|
|
790
|
+
_replace(change.path, current, restored)
|
|
791
|
+
else:
|
|
792
|
+
if current.data != change.after or (
|
|
793
|
+
change.after is not None and current.mode != change.before.mode
|
|
794
|
+
):
|
|
795
|
+
raise RuntimeError(
|
|
796
|
+
f"Rollback preserves concurrently changed artifact: {change.path}"
|
|
797
|
+
)
|
|
798
|
+
_replace(
|
|
799
|
+
change.path, current, change.before.data, mode=change.before.mode
|
|
800
|
+
)
|
|
801
|
+
except (RuntimeError, OSError) as exc:
|
|
802
|
+
conflicts.append(str(exc))
|
|
803
|
+
return conflicts
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _recover_changes(
|
|
807
|
+
setup: Setup,
|
|
808
|
+
changes: list[_Change],
|
|
809
|
+
operation: str,
|
|
810
|
+
cause: RuntimeError | OSError | KeyboardInterrupt,
|
|
811
|
+
) -> NoReturn:
|
|
812
|
+
"""Recover owned preimages and surface failure or cancellation with conflicts."""
|
|
813
|
+
conflicts: list[str] = []
|
|
814
|
+
cancellation: KeyboardInterrupt | None = None
|
|
815
|
+
try:
|
|
816
|
+
with _defer_cancellation():
|
|
817
|
+
conflicts = _rollback(changes, setup.data_root, setup.plugin)
|
|
818
|
+
try:
|
|
819
|
+
_reload(setup)
|
|
820
|
+
except RuntimeError as reload_error:
|
|
821
|
+
conflicts.append(f"Rollback config reload failed: {reload_error}")
|
|
822
|
+
except KeyboardInterrupt as interrupted:
|
|
823
|
+
cancellation = interrupted
|
|
824
|
+
suffix = (
|
|
825
|
+
f"; recovery needs judgment: {'; '.join(conflicts)}"
|
|
826
|
+
if conflicts
|
|
827
|
+
else "; owned changes rolled back"
|
|
828
|
+
)
|
|
829
|
+
if isinstance(cause, KeyboardInterrupt):
|
|
830
|
+
cause.add_note(f"{operation} cancelled{suffix}")
|
|
831
|
+
raise cause
|
|
832
|
+
if cancellation is not None:
|
|
833
|
+
cancellation.add_note(f"{operation} cancelled after failure: {cause}{suffix}")
|
|
834
|
+
raise cancellation from cause
|
|
835
|
+
raise RuntimeError(f"{operation} failed: {cause}{suffix}") from cause
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@contextmanager
|
|
839
|
+
def _operation_lock(paths: _Paths) -> Iterator[None]:
|
|
840
|
+
"""Serialize config, deployment-root and workspace mutations without waiting."""
|
|
841
|
+
config_descriptor: int | None = None
|
|
842
|
+
deployment_descriptor: int | None = None
|
|
843
|
+
try:
|
|
844
|
+
config_descriptor = os.open(
|
|
845
|
+
paths.config.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
846
|
+
)
|
|
847
|
+
try:
|
|
848
|
+
fcntl.flock(config_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
849
|
+
except BlockingIOError as exc:
|
|
850
|
+
raise RuntimeError(
|
|
851
|
+
"Another RexDeck lifecycle operation is using the shared config"
|
|
852
|
+
) from exc
|
|
853
|
+
_validate_data_root(paths.data_root, paths.plugin)
|
|
854
|
+
paths.data_root.mkdir(parents=True, exist_ok=True)
|
|
855
|
+
_validate_data_root(paths.data_root, paths.plugin)
|
|
856
|
+
deployment_descriptor = os.open(
|
|
857
|
+
paths.data_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
858
|
+
)
|
|
859
|
+
try:
|
|
860
|
+
fcntl.flock(deployment_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
861
|
+
except BlockingIOError as exc:
|
|
862
|
+
raise RuntimeError(
|
|
863
|
+
"Another RexDeck lifecycle operation is using the Files data root"
|
|
864
|
+
) from exc
|
|
865
|
+
descriptor = os.open(
|
|
866
|
+
paths.workspace / "lifecycle.lock",
|
|
867
|
+
os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW,
|
|
868
|
+
0o600,
|
|
869
|
+
)
|
|
870
|
+
with os.fdopen(descriptor, "r+b") as stream:
|
|
871
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
872
|
+
raise RuntimeError("Lifecycle lock is not a regular file")
|
|
873
|
+
try:
|
|
874
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
875
|
+
except BlockingIOError as exc:
|
|
876
|
+
raise RuntimeError(
|
|
877
|
+
"Another RexDeck lifecycle operation is in progress; inspect status"
|
|
878
|
+
) from exc
|
|
879
|
+
terminating = (signal.SIGTERM, signal.SIGHUP)
|
|
880
|
+
previous = (
|
|
881
|
+
{signum: signal.getsignal(signum) for signum in terminating}
|
|
882
|
+
if threading.current_thread() is threading.main_thread()
|
|
883
|
+
else {}
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
def interrupt(signum: int, _frame: FrameType | None) -> None:
|
|
887
|
+
raise KeyboardInterrupt(f"Received signal {signum}")
|
|
888
|
+
|
|
889
|
+
try:
|
|
890
|
+
for signum in previous:
|
|
891
|
+
signal.signal(signum, interrupt)
|
|
892
|
+
yield
|
|
893
|
+
finally:
|
|
894
|
+
for signum, handler in previous.items():
|
|
895
|
+
signal.signal(signum, handler)
|
|
896
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
897
|
+
except OSError as exc:
|
|
898
|
+
raise RuntimeError(f"Cannot lock the owned lifecycle operation: {exc}") from exc
|
|
899
|
+
finally:
|
|
900
|
+
if deployment_descriptor is not None:
|
|
901
|
+
os.close(deployment_descriptor)
|
|
902
|
+
if config_descriptor is not None:
|
|
903
|
+
os.close(config_descriptor)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _transaction(
|
|
907
|
+
setup: Setup,
|
|
908
|
+
paths: _Paths,
|
|
909
|
+
changes: list[_Change],
|
|
910
|
+
receipt: dict[str, object],
|
|
911
|
+
before: _File,
|
|
912
|
+
operation: str,
|
|
913
|
+
) -> None:
|
|
914
|
+
"""Publish owned changes and receipt only after native reload verification."""
|
|
915
|
+
for change in changes:
|
|
916
|
+
if change.after is not None and len(change.after) > _MAX_READ:
|
|
917
|
+
raise RuntimeError(
|
|
918
|
+
f"Planned owned file exceeds {_MAX_READ} bytes: {change.path}"
|
|
919
|
+
)
|
|
920
|
+
applied: list[_Change] = []
|
|
921
|
+
try:
|
|
922
|
+
for change in changes:
|
|
923
|
+
_validate_data_root(paths.data_root, paths.plugin)
|
|
924
|
+
if change.path == paths.module and change.after is not None:
|
|
925
|
+
paths.module.parent.mkdir(parents=True, exist_ok=True)
|
|
926
|
+
_validate_data_root(paths.data_root, paths.plugin)
|
|
927
|
+
if paths.selected.resolve(strict=True) != paths.config:
|
|
928
|
+
raise RuntimeError(
|
|
929
|
+
"Selected Rex config symlink changed during operation"
|
|
930
|
+
)
|
|
931
|
+
applied.append(change)
|
|
932
|
+
_replace(change.path, change.before, change.after)
|
|
933
|
+
_validate_data_root(paths.data_root, paths.plugin)
|
|
934
|
+
if paths.selected.resolve(strict=True) != paths.config:
|
|
935
|
+
raise RuntimeError(
|
|
936
|
+
"Selected Rex config symlink changed before config validation"
|
|
937
|
+
)
|
|
938
|
+
_check(setup)
|
|
939
|
+
_reload(setup)
|
|
940
|
+
_validate_data_root(paths.data_root, paths.plugin)
|
|
941
|
+
if paths.selected.resolve(strict=True) != paths.config:
|
|
942
|
+
raise RuntimeError("Selected Rex config symlink changed during reload")
|
|
943
|
+
if receipt["applied"]:
|
|
944
|
+
_current_bundle(setup, paths, receipt)
|
|
945
|
+
_expect_action(setup, bool(receipt["enabled"]))
|
|
946
|
+
publication = _Change(paths.receipt, before, _json(receipt))
|
|
947
|
+
applied.append(publication)
|
|
948
|
+
_replace(publication.path, publication.before, publication.after)
|
|
949
|
+
except (RuntimeError, OSError, KeyboardInterrupt) as exc:
|
|
950
|
+
_recover_changes(setup, applied, operation, exc)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _sibling(setup: Setup) -> tuple[_Paths, Setup]:
|
|
954
|
+
"""Resolve the other integration only when its loader needs ownership checks."""
|
|
955
|
+
sibling = replace(setup, plugin="git" if setup.plugin == "files" else "files")
|
|
956
|
+
return _paths(sibling), sibling
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _unowned(paths: _Paths, setup: Setup) -> None:
|
|
960
|
+
"""Reject foreign selected artifacts and validate any existing sibling loader."""
|
|
961
|
+
# Only a validated sibling receipt can account for another loader.
|
|
962
|
+
for path in (
|
|
963
|
+
paths.module,
|
|
964
|
+
paths.workspace / "hub.json",
|
|
965
|
+
paths.workspace / "hub-state",
|
|
966
|
+
paths.workspace / "runtime",
|
|
967
|
+
paths.config.parent / "rexdeck_palette.lua",
|
|
968
|
+
):
|
|
969
|
+
if path.exists() or path.is_symlink():
|
|
970
|
+
raise RuntimeError(
|
|
971
|
+
f"Unowned existing artifact: {path}; use a fresh native workspace"
|
|
972
|
+
)
|
|
973
|
+
data = _file(paths.config).data or b""
|
|
974
|
+
if b"-- BEGIN REXDECK" not in data and b"-- END REXDECK" not in data:
|
|
975
|
+
return
|
|
976
|
+
sibling, sibling_setup = _sibling(setup)
|
|
977
|
+
receipt = _receipt(sibling, sibling_setup)
|
|
978
|
+
if receipt is not None and receipt["applied"]:
|
|
979
|
+
_owned_config(sibling, receipt)
|
|
980
|
+
data = _without_loader(data, _loader(sibling))
|
|
981
|
+
if b"-- BEGIN REXDECK" in data or b"-- END REXDECK" in data:
|
|
982
|
+
raise RuntimeError("Unowned or old RexDeck loader; nothing changed")
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def inspect_setup(setup: Setup, *, check_tools: bool = True) -> dict[str, object]:
|
|
986
|
+
"""Inspect ownership, optionally including selected integration prerequisites."""
|
|
987
|
+
paths = _paths(setup)
|
|
988
|
+
receipt = _receipt(paths, setup)
|
|
989
|
+
prerequisites: dict[str, object] = {}
|
|
990
|
+
conflicts = cast(list[str], receipt["conflicts"]).copy() if receipt else []
|
|
991
|
+
if check_tools:
|
|
992
|
+
try:
|
|
993
|
+
prerequisites.update(_selected_tools(setup))
|
|
994
|
+
_sources(paths)
|
|
995
|
+
except RuntimeError as exc:
|
|
996
|
+
prerequisites["error"] = str(exc)
|
|
997
|
+
try:
|
|
998
|
+
_endpoint_policy(setup)
|
|
999
|
+
if receipt is None:
|
|
1000
|
+
_unowned(paths, setup)
|
|
1001
|
+
_expect_action(setup, False)
|
|
1002
|
+
elif receipt["applied"]:
|
|
1003
|
+
_owned_config(paths, receipt)
|
|
1004
|
+
_expect_action(setup, bool(receipt["enabled"]))
|
|
1005
|
+
except RuntimeError as exc:
|
|
1006
|
+
conflicts.append(str(exc))
|
|
1007
|
+
return {
|
|
1008
|
+
"state": "conflict" if conflicts else "inspected",
|
|
1009
|
+
"server": setup.server,
|
|
1010
|
+
"config": str(paths.selected),
|
|
1011
|
+
"config_target": str(paths.config),
|
|
1012
|
+
"owned": receipt is not None,
|
|
1013
|
+
"applied": bool(receipt and receipt["applied"]),
|
|
1014
|
+
"enabled": bool(receipt and receipt["enabled"]),
|
|
1015
|
+
"owned_files": [str(paths.module), str(paths.receipt)],
|
|
1016
|
+
"loader": _loader(paths).decode(),
|
|
1017
|
+
"prerequisites": prerequisites,
|
|
1018
|
+
"conflicts": conflicts,
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def apply_setup(
|
|
1023
|
+
setup: Setup, *, expected_tool: QualifiedTool | None = None
|
|
1024
|
+
) -> dict[str, object]:
|
|
1025
|
+
"""Apply owned bindings only while any supplied qualification remains current."""
|
|
1026
|
+
paths = _paths(setup)
|
|
1027
|
+
_receipt(paths, setup) # Refuse unknown ownership before creating even a lock.
|
|
1028
|
+
_endpoint_policy(setup)
|
|
1029
|
+
if not paths.receipt.exists():
|
|
1030
|
+
_unowned(paths, setup)
|
|
1031
|
+
_expect_action(setup, False)
|
|
1032
|
+
try:
|
|
1033
|
+
paths.workspace.mkdir(parents=True, exist_ok=True)
|
|
1034
|
+
except OSError as exc:
|
|
1035
|
+
raise RuntimeError(f"Cannot create owned setup workspace: {exc}") from exc
|
|
1036
|
+
with _operation_lock(paths):
|
|
1037
|
+
before = _file(paths.receipt)
|
|
1038
|
+
receipt = _receipt(paths, setup)
|
|
1039
|
+
config = _file(paths.config)
|
|
1040
|
+
assert config.data is not None
|
|
1041
|
+
previous_module = _file(paths.module)
|
|
1042
|
+
if receipt is None:
|
|
1043
|
+
_unowned(paths, setup)
|
|
1044
|
+
_expect_action(setup, False)
|
|
1045
|
+
elif receipt["applied"]:
|
|
1046
|
+
_owned_config(paths, receipt)
|
|
1047
|
+
else:
|
|
1048
|
+
_unowned(paths, setup)
|
|
1049
|
+
_expect_action(setup, False)
|
|
1050
|
+
source, launcher = _sources(paths)
|
|
1051
|
+
tools = _selected_tools(setup)
|
|
1052
|
+
enabled = bool(receipt and receipt["applied"] and receipt["enabled"])
|
|
1053
|
+
module = _module(setup, source, launcher, tools, enabled)
|
|
1054
|
+
changes: list[_Change] = []
|
|
1055
|
+
if previous_module.data != module:
|
|
1056
|
+
changes.append(_Change(paths.module, previous_module, module))
|
|
1057
|
+
if receipt is None or not receipt["applied"]:
|
|
1058
|
+
separator = "" if not config.data or config.data.endswith(b"\n") else "\n"
|
|
1059
|
+
changes.append(
|
|
1060
|
+
_Change(
|
|
1061
|
+
paths.config,
|
|
1062
|
+
config,
|
|
1063
|
+
config.data + separator.encode() + _loader(paths),
|
|
1064
|
+
True,
|
|
1065
|
+
)
|
|
1066
|
+
)
|
|
1067
|
+
else:
|
|
1068
|
+
separator = cast(str, receipt["loader_separator"])
|
|
1069
|
+
receipt = {
|
|
1070
|
+
"version": 3,
|
|
1071
|
+
"kind": f"native-{setup.plugin}",
|
|
1072
|
+
"project": str(paths.project),
|
|
1073
|
+
"workspace": str(paths.workspace),
|
|
1074
|
+
"data_root": str(paths.data_root),
|
|
1075
|
+
"config": str(paths.selected),
|
|
1076
|
+
"config_target": str(paths.config),
|
|
1077
|
+
"server": setup.server,
|
|
1078
|
+
"real_server": setup.real_server,
|
|
1079
|
+
"applied": True,
|
|
1080
|
+
"enabled": enabled,
|
|
1081
|
+
"module_sha256": _digest(module),
|
|
1082
|
+
"module_mode": previous_module.mode,
|
|
1083
|
+
"source_sha256": _digest(source),
|
|
1084
|
+
"launcher_sha256": _digest(launcher),
|
|
1085
|
+
"tools": tools,
|
|
1086
|
+
"loader_separator": separator,
|
|
1087
|
+
"conflicts": [],
|
|
1088
|
+
}
|
|
1089
|
+
_require_qualified_tool(setup, tools, expected_tool)
|
|
1090
|
+
_transaction(setup, paths, changes, receipt, before, "Apply")
|
|
1091
|
+
return {
|
|
1092
|
+
"state": "applied",
|
|
1093
|
+
"enabled": enabled,
|
|
1094
|
+
"server": setup.server,
|
|
1095
|
+
"changed": [str(change.path) for change in changes],
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def start(
|
|
1100
|
+
setup: Setup, *, expected_tool: QualifiedTool | None = None
|
|
1101
|
+
) -> dict[str, object]:
|
|
1102
|
+
"""Enable the current owned bundle without accepting stale qualification."""
|
|
1103
|
+
paths = _paths(setup)
|
|
1104
|
+
receipt = _receipt(paths, setup)
|
|
1105
|
+
if receipt is None or not receipt["applied"]:
|
|
1106
|
+
raise RuntimeError(
|
|
1107
|
+
f"{setup.plugin} is not installed; run rexdeck enable {setup.plugin}"
|
|
1108
|
+
)
|
|
1109
|
+
_endpoint_policy(setup)
|
|
1110
|
+
with _operation_lock(paths):
|
|
1111
|
+
before = _file(paths.receipt)
|
|
1112
|
+
receipt = _receipt(paths, setup)
|
|
1113
|
+
if receipt is None or not receipt["applied"]:
|
|
1114
|
+
raise RuntimeError("Setup ownership changed before selection")
|
|
1115
|
+
old = _file(paths.module)
|
|
1116
|
+
_current_bundle(setup, paths, receipt)
|
|
1117
|
+
if not receipt["enabled"]:
|
|
1118
|
+
_expect_action(setup, False)
|
|
1119
|
+
source, launcher = _sources(paths)
|
|
1120
|
+
tools = cast(dict[str, str], receipt["tools"])
|
|
1121
|
+
module = _module(setup, source, launcher, tools, True)
|
|
1122
|
+
receipt["enabled"] = True
|
|
1123
|
+
receipt["module_sha256"] = _digest(module)
|
|
1124
|
+
changes = [_Change(paths.module, old, module)] if old.data != module else []
|
|
1125
|
+
_require_qualified_tool(setup, tools, expected_tool)
|
|
1126
|
+
_transaction(setup, paths, changes, receipt, before, "Start")
|
|
1127
|
+
return {
|
|
1128
|
+
"state": "ready",
|
|
1129
|
+
"enabled": True,
|
|
1130
|
+
"server": setup.server,
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def status(setup: Setup) -> dict[str, object]:
|
|
1135
|
+
"""Report selected ownership and registration, not upstream process readiness."""
|
|
1136
|
+
paths = _paths(setup)
|
|
1137
|
+
receipt = _receipt(paths, setup)
|
|
1138
|
+
try:
|
|
1139
|
+
_endpoint_policy(setup)
|
|
1140
|
+
if receipt is not None and receipt["applied"]:
|
|
1141
|
+
_current_bundle(setup, paths, receipt)
|
|
1142
|
+
elif receipt is None:
|
|
1143
|
+
_unowned(paths, setup)
|
|
1144
|
+
_expect_action(setup, bool(receipt and receipt["enabled"]))
|
|
1145
|
+
except RuntimeError as exc:
|
|
1146
|
+
return {"state": "fault", "server": setup.server, "error": str(exc)}
|
|
1147
|
+
conflicts = receipt["conflicts"] if receipt else []
|
|
1148
|
+
return {
|
|
1149
|
+
"state": "conflict"
|
|
1150
|
+
if conflicts
|
|
1151
|
+
else "ready"
|
|
1152
|
+
if receipt and receipt["enabled"]
|
|
1153
|
+
else "disabled",
|
|
1154
|
+
"server": setup.server,
|
|
1155
|
+
"applied": bool(receipt and receipt["applied"]),
|
|
1156
|
+
"enabled": bool(receipt and receipt["enabled"]),
|
|
1157
|
+
"conflicts": conflicts,
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def disable(setup: Setup) -> dict[str, object]:
|
|
1162
|
+
"""Remove selected owned artifacts, preserving siblings and concurrent edits."""
|
|
1163
|
+
paths = _paths(setup)
|
|
1164
|
+
receipt = _receipt(paths, setup)
|
|
1165
|
+
_endpoint_policy(setup)
|
|
1166
|
+
if receipt is None:
|
|
1167
|
+
_unowned(paths, setup)
|
|
1168
|
+
_expect_action(setup, False)
|
|
1169
|
+
return {"state": "disabled", "server": setup.server, "conflicts": []}
|
|
1170
|
+
with _operation_lock(paths):
|
|
1171
|
+
before = _file(paths.receipt)
|
|
1172
|
+
receipt = _receipt(paths, setup)
|
|
1173
|
+
assert receipt is not None
|
|
1174
|
+
if not receipt["applied"]:
|
|
1175
|
+
return {
|
|
1176
|
+
"state": "conflict" if receipt["conflicts"] else "disabled",
|
|
1177
|
+
"server": setup.server,
|
|
1178
|
+
"conflicts": receipt["conflicts"],
|
|
1179
|
+
}
|
|
1180
|
+
config = _file(paths.config)
|
|
1181
|
+
assert config.data is not None
|
|
1182
|
+
changes: list[_Change] = []
|
|
1183
|
+
if receipt["applied"]:
|
|
1184
|
+
try:
|
|
1185
|
+
restored = _without_loader(
|
|
1186
|
+
config.data,
|
|
1187
|
+
_loader(paths),
|
|
1188
|
+
remove_separator=receipt["loader_separator"] == "\n",
|
|
1189
|
+
)
|
|
1190
|
+
# The first loader owns a separator needed by any following one.
|
|
1191
|
+
# Transfer it before removing that loader, so either disable order
|
|
1192
|
+
# restores a config that originally had no final newline.
|
|
1193
|
+
tail = config.data.partition(_loader(paths))[2]
|
|
1194
|
+
if receipt["loader_separator"] == "\n" and tail.startswith(
|
|
1195
|
+
b"-- BEGIN REXDECK"
|
|
1196
|
+
):
|
|
1197
|
+
sibling, sibling_setup = _sibling(setup)
|
|
1198
|
+
if tail.startswith(_loader(sibling)):
|
|
1199
|
+
sibling_before = _file(sibling.receipt)
|
|
1200
|
+
sibling_receipt = _receipt(sibling, sibling_setup)
|
|
1201
|
+
if sibling_receipt is None or not sibling_receipt["applied"]:
|
|
1202
|
+
raise RuntimeError("Following loader has no owned receipt")
|
|
1203
|
+
_owned_config(sibling, sibling_receipt)
|
|
1204
|
+
sibling_receipt["loader_separator"] = "\n"
|
|
1205
|
+
changes.append(
|
|
1206
|
+
_Change(
|
|
1207
|
+
sibling.receipt, sibling_before, _json(sibling_receipt)
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
except RuntimeError as exc:
|
|
1211
|
+
return {
|
|
1212
|
+
"state": "conflict",
|
|
1213
|
+
"server": setup.server,
|
|
1214
|
+
"conflicts": [str(exc)],
|
|
1215
|
+
}
|
|
1216
|
+
changes.append(_Change(paths.config, config, restored, True))
|
|
1217
|
+
conflicts: list[str] = []
|
|
1218
|
+
try:
|
|
1219
|
+
module = _file(paths.module)
|
|
1220
|
+
if module.data is not None:
|
|
1221
|
+
if (
|
|
1222
|
+
_digest(module.data) == receipt["module_sha256"]
|
|
1223
|
+
and module.mode == receipt["module_mode"]
|
|
1224
|
+
):
|
|
1225
|
+
changes.append(_Change(paths.module, module, None))
|
|
1226
|
+
else:
|
|
1227
|
+
conflicts.append(f"Changed native module preserved: {paths.module}")
|
|
1228
|
+
except RuntimeError as exc:
|
|
1229
|
+
conflicts.append(str(exc))
|
|
1230
|
+
receipt["enabled"] = False
|
|
1231
|
+
receipt["applied"] = False
|
|
1232
|
+
receipt["conflicts"] = conflicts
|
|
1233
|
+
_transaction(setup, paths, changes, receipt, before, "Disable")
|
|
1234
|
+
return {
|
|
1235
|
+
"state": "conflict" if conflicts else "disabled",
|
|
1236
|
+
"server": setup.server,
|
|
1237
|
+
"conflicts": conflicts,
|
|
1238
|
+
}
|