cdx-manager 0.18.3 → 0.18.5
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.
- package/README.md +42 -6
- package/VERSION +1 -0
- package/changelogs/CHANGELOGS_0_18_4.md +78 -0
- package/changelogs/CHANGELOGS_0_18_5.md +147 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +192 -12
- package/src/cli.py +24 -9
- package/src/cli_args.py +1 -1
- package/src/commands/maintenance.py +60 -0
- package/src/commands/tray.py +296 -2
- package/src/session_service.py +23 -1
- package/src/tray_contract.py +38 -2
- package/src/tray_defaults.py +48 -0
- package/src/tray_events.py +9 -1
- package/src/tray_install.py +125 -4
- package/src/tray_logics.py +141 -0
- package/src/tray_plugin_actions.py +62 -0
- package/src/tray_plugins.py +163 -0
- package/src/tray_restart.py +152 -0
- package/src/tray_terminal.py +80 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Replacing a companion that is currently running.
|
|
2
|
+
|
|
3
|
+
`cdx update` used to leave the running tray on its old binary and tell the user
|
|
4
|
+
to quit it from its menu. That is a correct thing to say and a poor thing to
|
|
5
|
+
have to do: the whole point of the companion is that nobody thinks about it.
|
|
6
|
+
|
|
7
|
+
The order here is what makes it safe, and it is the reverse of what looks
|
|
8
|
+
natural. The running companion is asked to stop *before* anything on disk moves,
|
|
9
|
+
so the replacement never has to fight a live process for its own files — on
|
|
10
|
+
Windows a running executable cannot be replaced at all, and a directory holding
|
|
11
|
+
one cannot reliably be renamed either. Stop, replace, start, and if the start
|
|
12
|
+
fails, put back the one that was proven to work and start that instead.
|
|
13
|
+
|
|
14
|
+
Nothing here kills anything. A companion that does not stop within the bounded
|
|
15
|
+
wait is left alone and reported, because a tray the user is reading a menu from
|
|
16
|
+
is not a process to terminate over a version number.
|
|
17
|
+
"""
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from .tray_instance import _is_alive, companion_instance, lock_path
|
|
22
|
+
|
|
23
|
+
# How long to wait for a companion to notice the request and exit.
|
|
24
|
+
#
|
|
25
|
+
# It polls several times a second, so this is generous by two orders of
|
|
26
|
+
# magnitude — deliberately. On Windows the menu is modal: while the user has it
|
|
27
|
+
# open, the message pump is stopped and the companion notices nothing at all.
|
|
28
|
+
# Ten seconds covers reading a menu and closing it; beyond that, the honest
|
|
29
|
+
# answer is to leave it running and say so.
|
|
30
|
+
STOP_TIMEOUT_SECONDS = 10.0
|
|
31
|
+
_POLL_SECONDS = 0.1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stop_path(env=None):
|
|
35
|
+
"""Where a stop request is written. Beside the lock, by the same rule."""
|
|
36
|
+
return os.path.join(os.path.dirname(lock_path(env=env)), "companion.stop")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def request_stop(env=None):
|
|
40
|
+
"""Ask the running companion to exit. False when it could not be asked."""
|
|
41
|
+
path = stop_path(env=env)
|
|
42
|
+
try:
|
|
43
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
44
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
45
|
+
handle.write("stop")
|
|
46
|
+
return True
|
|
47
|
+
except OSError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clear_stop(env=None):
|
|
52
|
+
"""Remove a stop request nobody consumed.
|
|
53
|
+
|
|
54
|
+
A companion that had already died leaves the request on disk, and the next
|
|
55
|
+
one to start would read it and quit immediately — a tray that refuses to
|
|
56
|
+
stay open, with nothing on screen explaining why.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
os.remove(stop_path(env=env))
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def wait_for_exit(pid, timeout=STOP_TIMEOUT_SECONDS, sleep=None, alive=None):
|
|
65
|
+
"""Wait for one process to go, up to `timeout`. True if it did."""
|
|
66
|
+
sleep = time.sleep if sleep is None else sleep
|
|
67
|
+
alive = _is_alive if alive is None else alive
|
|
68
|
+
deadline = time.monotonic() + timeout
|
|
69
|
+
while True:
|
|
70
|
+
if not alive(pid):
|
|
71
|
+
return True
|
|
72
|
+
if time.monotonic() >= deadline:
|
|
73
|
+
return False
|
|
74
|
+
sleep(_POLL_SECONDS)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def stop_running_companion(env=None, timeout=STOP_TIMEOUT_SECONDS, sleep=None, alive=None):
|
|
78
|
+
"""Stop the companion if one is running, and say what happened.
|
|
79
|
+
|
|
80
|
+
`stopped` is True only when a live companion was asked and observed to go.
|
|
81
|
+
Everything else is a reason, and every reason is one the caller can act on
|
|
82
|
+
without guessing: nothing was running, the request could not be written, or
|
|
83
|
+
it did not exit in time.
|
|
84
|
+
"""
|
|
85
|
+
instance = companion_instance(env=env)
|
|
86
|
+
pid = instance.get("pid")
|
|
87
|
+
if not pid:
|
|
88
|
+
# A stale lock is not a running tray, but it is worth clearing the stop
|
|
89
|
+
# request that may be sitting beside it from an earlier attempt.
|
|
90
|
+
clear_stop(env=env)
|
|
91
|
+
return {"stopped": False, "was_running": False, "reason": "no tray companion is running"}
|
|
92
|
+
if not request_stop(env=env):
|
|
93
|
+
return {
|
|
94
|
+
"stopped": False,
|
|
95
|
+
"was_running": True,
|
|
96
|
+
"pid": pid,
|
|
97
|
+
"reason": "the stop request could not be written",
|
|
98
|
+
}
|
|
99
|
+
if not wait_for_exit(pid, timeout=timeout, sleep=sleep, alive=alive):
|
|
100
|
+
# It is still running and it still owns its binary. Withdrawing the
|
|
101
|
+
# request matters: leaving it would stop the companion later, at a
|
|
102
|
+
# moment nobody connected to an update.
|
|
103
|
+
clear_stop(env=env)
|
|
104
|
+
return {
|
|
105
|
+
"stopped": False,
|
|
106
|
+
"was_running": True,
|
|
107
|
+
"pid": pid,
|
|
108
|
+
"reason": (
|
|
109
|
+
f"the running tray did not stop within {timeout:g}s. "
|
|
110
|
+
"An open tray menu blocks it on Windows; close the menu and run: cdx tray launch"
|
|
111
|
+
),
|
|
112
|
+
}
|
|
113
|
+
clear_stop(env=env)
|
|
114
|
+
return {"stopped": True, "was_running": True, "pid": pid}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def start_companion(executable, spawn=None, verify=None, env=None, settle=None):
|
|
118
|
+
"""Start a companion and confirm it is actually running.
|
|
119
|
+
|
|
120
|
+
Spawning is not evidence. A binary that exits immediately — a missing
|
|
121
|
+
library, a quarantined bundle, a companion that reads a stale stop request —
|
|
122
|
+
leaves a successful spawn and no tray, which is exactly the "reported
|
|
123
|
+
success before the replacement is usable" this must not do.
|
|
124
|
+
"""
|
|
125
|
+
from .tray_install import launch_command
|
|
126
|
+
|
|
127
|
+
spawn = spawn or _spawn_detached
|
|
128
|
+
settle = time.sleep if settle is None else settle
|
|
129
|
+
try:
|
|
130
|
+
spawn(launch_command(executable))
|
|
131
|
+
except Exception as error: # noqa: BLE001 - any launch failure is a reason
|
|
132
|
+
return {"started": False, "reason": str(error)}
|
|
133
|
+
# It has to register its lock before it counts as running, and it does that
|
|
134
|
+
# a moment after exec.
|
|
135
|
+
for _ in range(20):
|
|
136
|
+
settle(_POLL_SECONDS)
|
|
137
|
+
instance = (verify or companion_instance)(env=env)
|
|
138
|
+
if instance.get("pid"):
|
|
139
|
+
return {"started": True, "pid": instance["pid"]}
|
|
140
|
+
return {"started": False, "reason": "it was launched but never registered as running"}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _spawn_detached(command):
|
|
144
|
+
import subprocess
|
|
145
|
+
|
|
146
|
+
subprocess.Popen( # noqa: S603 (path comes from our own install state)
|
|
147
|
+
command,
|
|
148
|
+
stdout=subprocess.DEVNULL,
|
|
149
|
+
stderr=subprocess.DEVNULL,
|
|
150
|
+
stdin=subprocess.DEVNULL,
|
|
151
|
+
start_new_session=True,
|
|
152
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Which terminal a tray session row opens.
|
|
2
|
+
|
|
3
|
+
The companion picks one per platform today: Terminal.app on macOS, the first of
|
|
4
|
+
four names that answers on Linux. Someone who works in iTerm or Ghostty gets a
|
|
5
|
+
second terminal application opened beside the one they were already in, which is
|
|
6
|
+
the opposite of the shortcut the row is meant to be.
|
|
7
|
+
|
|
8
|
+
What is stored is an application, never a command, and that restriction is the
|
|
9
|
+
whole security argument. A preference that could hold a command line would be a
|
|
10
|
+
way to run anything, in a process the user did not start, triggered by clicking
|
|
11
|
+
a menu row — and it would be writable by anything that can write CDX's state.
|
|
12
|
+
The validation below is what makes "open my terminal" impossible to turn into
|
|
13
|
+
"run this". Nothing here reaches a shell: the value names an application and the
|
|
14
|
+
platform's own launcher resolves it.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
STATE_VERSION = 1
|
|
21
|
+
# An application name or a bundle identifier, and nothing that could be read as
|
|
22
|
+
# a command. No slashes, so it cannot name a path; no quotes, semicolons,
|
|
23
|
+
# ampersands, pipes, backticks or dollars, so it cannot carry a shell fragment;
|
|
24
|
+
# it must start with a letter or a digit, so it cannot be read as a flag.
|
|
25
|
+
_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
|
|
26
|
+
|
|
27
|
+
REFUSAL = (
|
|
28
|
+
"A terminal preference is an application name, not a command: "
|
|
29
|
+
"letters, digits, spaces, dots, dashes and underscores only. "
|
|
30
|
+
'Examples: "iTerm", "Ghostty", "WezTerm", "kitty", "wt".'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def state_path(base_dir):
|
|
35
|
+
return os.path.join(base_dir, "tray", "terminal.json")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def terminal_preference(base_dir):
|
|
39
|
+
"""The chosen terminal, or None for the platform's default.
|
|
40
|
+
|
|
41
|
+
Anything unreadable, unrecognised, or no longer valid reads as no
|
|
42
|
+
preference. A value that fails validation today must not be honoured just
|
|
43
|
+
because it was written before the rule existed.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
with open(state_path(base_dir), encoding="utf-8") as handle:
|
|
47
|
+
state = json.load(handle)
|
|
48
|
+
except (OSError, ValueError):
|
|
49
|
+
return None
|
|
50
|
+
if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
|
|
51
|
+
return None
|
|
52
|
+
name = state.get("terminal")
|
|
53
|
+
return name if isinstance(name, str) and _NAME.match(name) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def valid_terminal(name):
|
|
57
|
+
return isinstance(name, str) and bool(_NAME.match(name))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_terminal(base_dir, name):
|
|
61
|
+
"""Record a preferred terminal. Raises ValueError on anything else."""
|
|
62
|
+
if not valid_terminal(name):
|
|
63
|
+
raise ValueError(REFUSAL)
|
|
64
|
+
_write(base_dir, {"version": STATE_VERSION, "terminal": name})
|
|
65
|
+
return name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def clear_terminal(base_dir):
|
|
69
|
+
"""Return to the platform's default."""
|
|
70
|
+
_write(base_dir, {"version": STATE_VERSION, "terminal": None})
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write(base_dir, state):
|
|
75
|
+
path = state_path(base_dir)
|
|
76
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
77
|
+
temp = f"{path}.{os.getpid()}.tmp"
|
|
78
|
+
with open(temp, "w", encoding="utf-8") as handle:
|
|
79
|
+
json.dump(state, handle, indent=2)
|
|
80
|
+
os.replace(temp, path)
|