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/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
+ }