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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""RexDeck's explicit repository-local setup tools."""
|
rexdeck/cli.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""One-time native Rex target approval and independent integration lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Never
|
|
12
|
+
|
|
13
|
+
from rexdeck import dependencies, lifecycle, settings
|
|
14
|
+
|
|
15
|
+
_FAILURES = {"conflict", "failed", "fault"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Parser(argparse.ArgumentParser):
|
|
19
|
+
def error(self, message: str) -> Never:
|
|
20
|
+
print(json.dumps({"state": "failed", "error": message}), file=sys.stderr)
|
|
21
|
+
raise SystemExit(2)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _discover(config: Path) -> str:
|
|
25
|
+
# REX_SERVER is honored only here, never by subsequent saved-target commands.
|
|
26
|
+
env = {
|
|
27
|
+
key: value
|
|
28
|
+
for key, value in os.environ.items()
|
|
29
|
+
if key not in {"REX_SESSION", "REX_BLOCK", "REX_CLIENT"}
|
|
30
|
+
}
|
|
31
|
+
env["REX_CONFIG"] = str(config)
|
|
32
|
+
argv = [lifecycle._executable("rex"), "--autostart=false", "--timeout", "3s"]
|
|
33
|
+
if "REX_SERVER" in env:
|
|
34
|
+
# Native `server status` needs -S even when REX_SERVER is present.
|
|
35
|
+
lifecycle._socket_path(env["REX_SERVER"])
|
|
36
|
+
argv += ["-S", env["REX_SERVER"]]
|
|
37
|
+
result = lifecycle._run([*argv, "server", "status", "--json"], env)
|
|
38
|
+
if result.code != 0:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
"Cannot discover the Rex server; start Rex or pass setup --server UNIX_URL: "
|
|
41
|
+
+ result.output.decode(errors="replace").strip()
|
|
42
|
+
)
|
|
43
|
+
value = lifecycle._decode(result.output, "Rex server discovery")
|
|
44
|
+
server = value.get("endpoint")
|
|
45
|
+
if (
|
|
46
|
+
not isinstance(server, str)
|
|
47
|
+
or not server
|
|
48
|
+
or server != server.strip()
|
|
49
|
+
or not isinstance(value.get("instance_id"), str)
|
|
50
|
+
or not value["instance_id"]
|
|
51
|
+
):
|
|
52
|
+
raise RuntimeError("Rex server discovery lacks endpoint/instance identity")
|
|
53
|
+
lifecycle._socket_path(server)
|
|
54
|
+
return server
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _selected_status(setup: lifecycle.Setup) -> None:
|
|
58
|
+
current = lifecycle._server_status(setup, selected=True)
|
|
59
|
+
if current is None:
|
|
60
|
+
raise RuntimeError("Selected Rex server is not running")
|
|
61
|
+
endpoint = current.get("endpoint")
|
|
62
|
+
if not isinstance(endpoint, str) or (
|
|
63
|
+
lifecycle._socket_path(endpoint) != lifecycle._socket_path(setup.server)
|
|
64
|
+
):
|
|
65
|
+
raise RuntimeError("Selected server answered with a different socket endpoint")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _inspect(target: settings.SavedTarget) -> dict[str, object]:
|
|
69
|
+
"""Inspect the saved target without imposing plugin-specific prerequisites."""
|
|
70
|
+
setup = target.setup()
|
|
71
|
+
_selected_status(setup)
|
|
72
|
+
return lifecycle.inspect_setup(setup, check_tools=False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _configure(args: argparse.Namespace, path: Path) -> dict[str, object]:
|
|
76
|
+
"""Approve and persist one target without retargeting existing ownership."""
|
|
77
|
+
old = settings.load(path)
|
|
78
|
+
# Validate both XDG roots even when a saved workspace is reused.
|
|
79
|
+
workspace_default = settings.default_workspace()
|
|
80
|
+
settings.xdg_path("XDG_RUNTIME_DIR", Path("/tmp"))
|
|
81
|
+
if (
|
|
82
|
+
old is not None
|
|
83
|
+
and args.config is None
|
|
84
|
+
and args.server is None
|
|
85
|
+
and args.workspace is None
|
|
86
|
+
):
|
|
87
|
+
if args.real_server and not old.real_server:
|
|
88
|
+
raise RuntimeError("--real-server does not match the saved isolated target")
|
|
89
|
+
result = _inspect(old)
|
|
90
|
+
if result.get("state") in _FAILURES:
|
|
91
|
+
return result
|
|
92
|
+
return {"state": "configured", "settings": str(path), **old.document()}
|
|
93
|
+
|
|
94
|
+
if args.config is not None:
|
|
95
|
+
config = args.config
|
|
96
|
+
elif old is not None:
|
|
97
|
+
config = old.config
|
|
98
|
+
elif "REX_CONFIG" in os.environ:
|
|
99
|
+
configured = os.environ["REX_CONFIG"]
|
|
100
|
+
if not configured or "\x00" in configured:
|
|
101
|
+
raise RuntimeError("REX_CONFIG must name a nonempty config path")
|
|
102
|
+
config = Path(configured)
|
|
103
|
+
else:
|
|
104
|
+
config = (
|
|
105
|
+
settings.xdg_path("XDG_CONFIG_HOME", Path.home() / ".config")
|
|
106
|
+
/ "rex/init.lua"
|
|
107
|
+
)
|
|
108
|
+
config = config.parent.resolve(strict=True) / config.name
|
|
109
|
+
config_target = config.resolve(strict=True)
|
|
110
|
+
if not config_target.is_file():
|
|
111
|
+
raise RuntimeError("Selected Rex config must be a regular file")
|
|
112
|
+
workspace = (
|
|
113
|
+
args.workspace.resolve()
|
|
114
|
+
if args.workspace is not None
|
|
115
|
+
else old.workspace
|
|
116
|
+
if old is not None
|
|
117
|
+
else workspace_default
|
|
118
|
+
)
|
|
119
|
+
data_root = old.data_root if old is not None else settings.default_data_root()
|
|
120
|
+
server = lifecycle._canonical_server(
|
|
121
|
+
args.server
|
|
122
|
+
if args.server is not None
|
|
123
|
+
else (old.server if old is not None else _discover(config))
|
|
124
|
+
)
|
|
125
|
+
candidate = settings.SavedTarget(
|
|
126
|
+
workspace=workspace,
|
|
127
|
+
data_root=data_root,
|
|
128
|
+
config=config,
|
|
129
|
+
config_target=config_target,
|
|
130
|
+
server=server,
|
|
131
|
+
real_server=False,
|
|
132
|
+
)
|
|
133
|
+
selected_setup = candidate.setup()
|
|
134
|
+
_selected_status(selected_setup)
|
|
135
|
+
is_real = lifecycle._is_real_endpoint(selected_setup)
|
|
136
|
+
if args.real_server and not is_real:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
"--real-server was selected for an isolated server; remove the flag"
|
|
139
|
+
)
|
|
140
|
+
approval_saved = old is not None and (
|
|
141
|
+
old.config,
|
|
142
|
+
old.config_target,
|
|
143
|
+
old.workspace,
|
|
144
|
+
old.data_root,
|
|
145
|
+
old.server,
|
|
146
|
+
old.real_server,
|
|
147
|
+
) == (config, config_target, workspace, data_root, server, is_real)
|
|
148
|
+
interactive = sys.stdin.isatty()
|
|
149
|
+
if interactive and not approval_saved:
|
|
150
|
+
print(
|
|
151
|
+
f"Rex config: {config}\nResolved config: {config_target}\nRex server: {server}",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
if is_real and not args.real_server and not approval_saved:
|
|
155
|
+
if not interactive:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
"Real Rex target requires explicit approval: run rexdeck setup interactively "
|
|
158
|
+
"or pass setup --real-server for approved automation"
|
|
159
|
+
)
|
|
160
|
+
print(
|
|
161
|
+
"Approve this real Rex target for later integration writes? Type yes: ",
|
|
162
|
+
end="",
|
|
163
|
+
file=sys.stderr,
|
|
164
|
+
flush=True,
|
|
165
|
+
)
|
|
166
|
+
if sys.stdin.readline().strip() != "yes":
|
|
167
|
+
raise RuntimeError("Real Rex target was not approved; nothing saved")
|
|
168
|
+
candidate = settings.SavedTarget(
|
|
169
|
+
workspace=workspace,
|
|
170
|
+
data_root=data_root,
|
|
171
|
+
config=config,
|
|
172
|
+
config_target=config_target,
|
|
173
|
+
server=server,
|
|
174
|
+
real_server=is_real,
|
|
175
|
+
)
|
|
176
|
+
if old is not None and old != candidate:
|
|
177
|
+
raise RuntimeError(
|
|
178
|
+
"Cannot retarget existing saved integration ownership. Disable every integration "
|
|
179
|
+
"for the saved target, then deliberately remove "
|
|
180
|
+
f"{path} before setup with --workspace pointing to a fresh directory "
|
|
181
|
+
"for the new target; preserve the old ownership workspace"
|
|
182
|
+
)
|
|
183
|
+
result = _inspect(candidate)
|
|
184
|
+
if result.get("state") in _FAILURES:
|
|
185
|
+
return result
|
|
186
|
+
# Recheck the frozen symlink immediately before publishing the approved settings.
|
|
187
|
+
candidate.setup()
|
|
188
|
+
if candidate != old:
|
|
189
|
+
settings.save(path, candidate)
|
|
190
|
+
return {"state": "configured", "settings": str(path), **candidate.document()}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _enable(path: Path, target: settings.SavedTarget, plugin: str) -> dict[str, object]:
|
|
194
|
+
"""Carry one executable identity through both locked ownership transactions."""
|
|
195
|
+
setup = target.setup(plugin)
|
|
196
|
+
_selected_status(setup)
|
|
197
|
+
qualified_tool: lifecycle.QualifiedTool = dependencies.ensure(plugin)
|
|
198
|
+
# Version probes run outside config locks; revalidate the saved target and
|
|
199
|
+
# running server before publishing an integration.
|
|
200
|
+
current = settings.load(path)
|
|
201
|
+
if current is None or current != target:
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
"Saved Rex target changed during dependency preparation; no integration "
|
|
204
|
+
f"was registered. Inspect {path}, then rerun rexdeck enable {plugin}"
|
|
205
|
+
)
|
|
206
|
+
setup = current.setup(plugin)
|
|
207
|
+
_selected_status(setup)
|
|
208
|
+
result = lifecycle.apply_setup(setup, expected_tool=qualified_tool)
|
|
209
|
+
if result.get("state") == "applied":
|
|
210
|
+
return lifecycle.start(setup, expected_tool=qualified_tool)
|
|
211
|
+
if result.get("state") not in _FAILURES:
|
|
212
|
+
raise RuntimeError(
|
|
213
|
+
f"{plugin} apply did not succeed; integration was not started"
|
|
214
|
+
)
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main() -> int:
|
|
219
|
+
"""Dispatch lifecycle commands and isolate faults in aggregate status rows."""
|
|
220
|
+
parser = _Parser(description=__doc__)
|
|
221
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
222
|
+
configure = subcommands.add_parser("setup")
|
|
223
|
+
configure.add_argument("--config", type=Path)
|
|
224
|
+
configure.add_argument("--server")
|
|
225
|
+
configure.add_argument(
|
|
226
|
+
"--workspace", type=Path, help="Reuse an existing native Files workspace"
|
|
227
|
+
)
|
|
228
|
+
configure.add_argument(
|
|
229
|
+
"--real-server",
|
|
230
|
+
action="store_true",
|
|
231
|
+
help="Explicitly approve the real Rex target for subsequent integration writes",
|
|
232
|
+
)
|
|
233
|
+
for name in ("enable", "disable", "status"):
|
|
234
|
+
command = subcommands.add_parser(name)
|
|
235
|
+
command.add_argument(
|
|
236
|
+
"plugin", choices=lifecycle.PLUGINS, nargs="?" if name == "status" else None
|
|
237
|
+
)
|
|
238
|
+
args = parser.parse_args()
|
|
239
|
+
try:
|
|
240
|
+
path = settings.settings_path()
|
|
241
|
+
if args.command == "setup":
|
|
242
|
+
result = _configure(args, path)
|
|
243
|
+
else:
|
|
244
|
+
target = settings.load(path)
|
|
245
|
+
if target is None:
|
|
246
|
+
raise RuntimeError("No saved Rex target; run rexdeck setup first")
|
|
247
|
+
if args.command == "enable":
|
|
248
|
+
result = _enable(path, target, args.plugin)
|
|
249
|
+
elif args.command == "disable":
|
|
250
|
+
result = lifecycle.disable(target.setup(args.plugin))
|
|
251
|
+
else:
|
|
252
|
+
if args.plugin:
|
|
253
|
+
result = lifecycle.status(target.setup(args.plugin))
|
|
254
|
+
else:
|
|
255
|
+
plugins: dict[str, dict[str, object]] = {}
|
|
256
|
+
for plugin in lifecycle.PLUGINS:
|
|
257
|
+
try:
|
|
258
|
+
plugins[plugin] = lifecycle.status(target.setup(plugin))
|
|
259
|
+
except (
|
|
260
|
+
OSError,
|
|
261
|
+
ValueError,
|
|
262
|
+
RuntimeError,
|
|
263
|
+
subprocess.SubprocessError,
|
|
264
|
+
) as error:
|
|
265
|
+
plugins[plugin] = {
|
|
266
|
+
"state": "fault",
|
|
267
|
+
"server": target.server,
|
|
268
|
+
"error": f"Cannot inspect {plugin}: {error}",
|
|
269
|
+
}
|
|
270
|
+
failed = any(
|
|
271
|
+
row.get("state") in _FAILURES for row in plugins.values()
|
|
272
|
+
)
|
|
273
|
+
result = {
|
|
274
|
+
"state": "fault"
|
|
275
|
+
if failed
|
|
276
|
+
else "ready"
|
|
277
|
+
if any(row.get("enabled") for row in plugins.values())
|
|
278
|
+
else "disabled",
|
|
279
|
+
"plugins": plugins,
|
|
280
|
+
}
|
|
281
|
+
print(json.dumps(result, ensure_ascii=True, indent=2))
|
|
282
|
+
return 1 if result.get("state") in _FAILURES else 0
|
|
283
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
284
|
+
print(
|
|
285
|
+
json.dumps({"state": "failed", "error": str(error)}, ensure_ascii=True),
|
|
286
|
+
file=sys.stderr,
|
|
287
|
+
)
|
|
288
|
+
return 1
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
raise SystemExit(main())
|
rexdeck/dependencies.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Qualify selected preinstalled tools and explain owner-managed installation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rexdeck import lifecycle
|
|
12
|
+
|
|
13
|
+
_TOOLS = {"files": "yazi", "git": "lazygit"}
|
|
14
|
+
_VERSIONS = {
|
|
15
|
+
"yazi": ("26.9.1",),
|
|
16
|
+
"lazygit": ("0.66.2", "0.66.3"),
|
|
17
|
+
}
|
|
18
|
+
_RELEASES = {
|
|
19
|
+
"yazi": "https://github.com/sxyazi/yazi/releases/tag/v26.9.1",
|
|
20
|
+
"lazygit": "https://github.com/jesseduffield/lazygit/releases/tag/v0.66.3",
|
|
21
|
+
}
|
|
22
|
+
_PATTERNS = {
|
|
23
|
+
"yazi": (
|
|
24
|
+
r"^Yazi(?:[ \t]+|[ \t]*\r?\n[ \t]*Version:[ \t]*)"
|
|
25
|
+
r"(\d+\.\d+\.\d+)(?:\s|$)"
|
|
26
|
+
),
|
|
27
|
+
"lazygit": r"(?:^|,\s*)version=(\d+\.\d+\.\d+)(?=,|\s*$)",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve(name: str) -> str | None:
|
|
32
|
+
"""Search absolute PATH entries while preserving lexical executable identity."""
|
|
33
|
+
search_path = os.pathsep.join(
|
|
34
|
+
entry
|
|
35
|
+
for entry in os.environ.get("PATH", os.defpath).split(os.pathsep)
|
|
36
|
+
if Path(entry).is_absolute()
|
|
37
|
+
)
|
|
38
|
+
found = shutil.which(name, path=search_path) if search_path else None
|
|
39
|
+
return str(Path(found).absolute()) if found is not None else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _probe(executable: str, name: str) -> str:
|
|
43
|
+
"""Require an explicitly qualified upstream version before registration."""
|
|
44
|
+
result = lifecycle._run([executable, "--version"], dict(os.environ))
|
|
45
|
+
text = result.output.decode(errors="replace").strip()
|
|
46
|
+
version = re.search(_PATTERNS[name], text, re.MULTILINE)
|
|
47
|
+
if result.code != 0 or version is None:
|
|
48
|
+
raise RuntimeError(f"Cannot identify {name} version at {executable}: {text}")
|
|
49
|
+
value = version.group(1)
|
|
50
|
+
if value not in _VERSIONS[name]:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Unqualified {name} version {value} at {executable}; "
|
|
53
|
+
f"RexDeck qualifies {name} {' or '.join(_VERSIONS[name])} only"
|
|
54
|
+
)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _manual(plugin: str) -> str:
|
|
59
|
+
"""Give owner-run, version-qualified installation and exact rerun instructions."""
|
|
60
|
+
tool = _TOOLS[plugin]
|
|
61
|
+
return (
|
|
62
|
+
f"Manually install {tool} {' or '.join(_VERSIONS[tool])} from {_RELEASES[tool]}. "
|
|
63
|
+
f"On macOS with existing Homebrew, inspect brew info {tool}; run brew install "
|
|
64
|
+
f"{tool} only if it selects a qualified version and you accept its dependency, "
|
|
65
|
+
"build and upgrade effects. Otherwise skip Homebrew and use the qualified "
|
|
66
|
+
f"upstream release. Verify with {tool} --version, put the executable in a "
|
|
67
|
+
f"trusted absolute PATH directory, then rerun: rexdeck enable {plugin}. "
|
|
68
|
+
"RexDeck never installs, upgrades or removes upstream tools."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ensure(plugin: str) -> lifecycle.QualifiedTool:
|
|
73
|
+
"""Validate only the selected preinstalled tool; never prompt or run an installer."""
|
|
74
|
+
tool = _TOOLS[plugin]
|
|
75
|
+
if plugin == "git" and _resolve("git") is None:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"Git is a preinstalled prerequisite for the Git integration and was not "
|
|
78
|
+
"found on PATH. Install Git using https://git-scm.com/downloads, make git "
|
|
79
|
+
"available on PATH, then rerun: rexdeck enable git. RexDeck never installs Git"
|
|
80
|
+
)
|
|
81
|
+
executable = _resolve(tool)
|
|
82
|
+
if executable is None:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"Missing {tool} on PATH; no integration was registered. {_manual(plugin)}"
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
qualified = lifecycle.QualifiedTool.capture(executable)
|
|
88
|
+
_probe(executable, tool)
|
|
89
|
+
qualified.require_current()
|
|
90
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
91
|
+
raise RuntimeError(f"{exc}. {_manual(plugin)}") from exc
|
|
92
|
+
return qualified
|