manigent-daemon 0.1.4__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.
- manigent_daemon/agent_processes.py +276 -0
- manigent_daemon/autostart.py +514 -0
- manigent_daemon/cli.py +756 -0
- manigent_daemon/client.py +386 -0
- manigent_daemon/commands.py +492 -0
- manigent_daemon/config.py +455 -0
- manigent_daemon/discovery.py +36 -0
- manigent_daemon/protocol.py +177 -0
- manigent_daemon/terminal.py +510 -0
- manigent_daemon/upgrade.py +260 -0
- manigent_daemon-0.1.4.dist-info/METADATA +141 -0
- manigent_daemon-0.1.4.dist-info/RECORD +16 -0
- manigent_daemon-0.1.4.dist-info/WHEEL +5 -0
- manigent_daemon-0.1.4.dist-info/entry_points.txt +3 -0
- manigent_daemon-0.1.4.dist-info/licenses/LICENSE +21 -0
- manigent_daemon-0.1.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""
|
|
3
|
+
manigent_daemon/autostart.py
|
|
4
|
+
|
|
5
|
+
Start this computer's connection again at login, without being asked.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
Before this module the connection was detached but unsupervised: it survived the
|
|
10
|
+
terminal that started it and did not survive a reboot. That is the wrong
|
|
11
|
+
asymmetry, because almost nobody stops their agents on purpose — they reboot,
|
|
12
|
+
close the lid for the weekend, or take a system update overnight. Every one of
|
|
13
|
+
those looked identical to "Manigent is broken": the web app said the computer
|
|
14
|
+
was off, which was true, and offered no reason to think a single command would
|
|
15
|
+
fix it. Registering with the OS login manager is the one change that makes most
|
|
16
|
+
of those reports disappear.
|
|
17
|
+
|
|
18
|
+
## One account, one unit
|
|
19
|
+
|
|
20
|
+
A unit is per manigent account, named after it, exactly as the credential,
|
|
21
|
+
pidfile and logs are. Two accounts on one machine get two units that start two
|
|
22
|
+
processes; disabling one leaves the other running. Anything shared would make
|
|
23
|
+
"stop starting my work account at login" impossible to express.
|
|
24
|
+
|
|
25
|
+
## Deliberately not a supervisor
|
|
26
|
+
|
|
27
|
+
launchd's ``KeepAlive`` and systemd's ``Restart=`` are both OFF. They would
|
|
28
|
+
fight ``manigent computer stop``: that command signals the process directly,
|
|
29
|
+
which both managers read as an unexpected death and answer by starting it again
|
|
30
|
+
within seconds. A user who stops their computer's connection and watches it come
|
|
31
|
+
back has been told the product ignores them, and that is a worse failure than
|
|
32
|
+
the crash-recovery this gives up. The connection already reconnects across
|
|
33
|
+
network failures on its own (``client.run_forever``), so what is lost is
|
|
34
|
+
recovery from a hard crash of the process itself — which the next login fixes
|
|
35
|
+
anyway.
|
|
36
|
+
|
|
37
|
+
## What is written where
|
|
38
|
+
|
|
39
|
+
- macOS — ``~/Library/LaunchAgents/ai.manigent.computer.<account>.plist``,
|
|
40
|
+
loaded with ``launchctl bootstrap gui/<uid>`` (falling back to ``load -w`` on
|
|
41
|
+
older systems).
|
|
42
|
+
- Linux — ``~/.config/systemd/user/manigent-computer-<account>.service``,
|
|
43
|
+
enabled with ``systemctl --user enable``. ``loginctl enable-linger`` is
|
|
44
|
+
attempted too, because without it the unit stops at logout, which on a
|
|
45
|
+
headless box means it never runs at all.
|
|
46
|
+
- Windows — unsupported here and reported as such rather than half-done. The
|
|
47
|
+
honest answer ("run ``manigent computer start``") beats a Task Scheduler
|
|
48
|
+
entry this module could not reliably remove again.
|
|
49
|
+
|
|
50
|
+
Every function reports rather than raises: a failure to register autostart must
|
|
51
|
+
never fail the install that asked for it. The connection still works; it just
|
|
52
|
+
will not come back by itself, and the caller gets a sentence saying so.
|
|
53
|
+
"""
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import hashlib
|
|
57
|
+
import os
|
|
58
|
+
import plistlib
|
|
59
|
+
import shutil
|
|
60
|
+
import subprocess
|
|
61
|
+
import sys
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
from manigent_daemon import config as cfg
|
|
66
|
+
|
|
67
|
+
# The reverse-DNS label launchd knows this by, and the prefix of the systemd
|
|
68
|
+
# unit name. Public because `status` output and the uninstall path both key off
|
|
69
|
+
# it, and because a user grepping their LaunchAgents folder should find one
|
|
70
|
+
# obvious name.
|
|
71
|
+
LABEL_PREFIX = "ai.manigent.computer"
|
|
72
|
+
SYSTEMD_PREFIX = "manigent-computer"
|
|
73
|
+
|
|
74
|
+
# How long any launchctl / systemctl call may take. These are local IPC calls
|
|
75
|
+
# that either answer at once or are wedged; waiting longer helps nobody.
|
|
76
|
+
_CMD_TIMEOUT_SECONDS = 20
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class AutostartState:
|
|
81
|
+
"""Whether this account's connection is registered to start at login.
|
|
82
|
+
|
|
83
|
+
``supported`` false means this platform has no mechanism we manage, and
|
|
84
|
+
``installed`` is then meaningless — callers must check ``supported`` first
|
|
85
|
+
rather than reading ``installed=False`` as "the user should enable it".
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
supported: bool
|
|
89
|
+
installed: bool
|
|
90
|
+
detail: str
|
|
91
|
+
# The plist / unit file, when there is one. Shown so a user can read or
|
|
92
|
+
# delete by hand what we wrote on their machine.
|
|
93
|
+
path: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _account(username: str = "") -> str:
|
|
97
|
+
"""Select the account these paths belong to, and return its name."""
|
|
98
|
+
if username:
|
|
99
|
+
cfg.use_account(username)
|
|
100
|
+
return cfg.active_account()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run(argv: list[str]) -> tuple[bool, str]:
|
|
104
|
+
"""Run a manager command. ``(ok, output)``; never raises.
|
|
105
|
+
|
|
106
|
+
Both managers are chatty on stderr and silent on success, so the message is
|
|
107
|
+
whichever stream spoke — that is what a user needs to see when enabling
|
|
108
|
+
autostart did not take.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
argv,
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
timeout=_CMD_TIMEOUT_SECONDS,
|
|
116
|
+
)
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
return False, f"{argv[0]} is not available on this computer"
|
|
119
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
120
|
+
return False, str(e)
|
|
121
|
+
message = (result.stderr or result.stdout or "").strip()
|
|
122
|
+
return result.returncode == 0, message
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_argv(username: str) -> list[str]:
|
|
126
|
+
"""The command the OS should run at login, as an absolute argv.
|
|
127
|
+
|
|
128
|
+
``sys.executable -m manigent_daemon.cli run`` rather than the
|
|
129
|
+
``manigent-computer`` console script: launchd and systemd start with a
|
|
130
|
+
minimal PATH that does not include ``~/.local/bin``, where uv puts the
|
|
131
|
+
script, so resolving the interpreter now is what makes the unit work at
|
|
132
|
+
login rather than only in the shell that created it.
|
|
133
|
+
|
|
134
|
+
The account is spelled out for the same reason ``_start_detached`` spells it
|
|
135
|
+
out — the logged-in account can change under a unit that runs for months,
|
|
136
|
+
and a connection that silently began reporting a different account's agents
|
|
137
|
+
would be the worst available bug.
|
|
138
|
+
|
|
139
|
+
``-P`` for the same reason ``cli_daemon._resolve`` passes it: ``python -m``
|
|
140
|
+
puts the working directory on ``sys.path``, and both units below run from
|
|
141
|
+
``$HOME``. A file named ``typing.py`` or ``httpx.py`` sitting in someone's
|
|
142
|
+
home directory would otherwise shadow the real module and the connection
|
|
143
|
+
would die in an import traceback at every login — the least debuggable
|
|
144
|
+
possible version of "my agents stopped coming back".
|
|
145
|
+
"""
|
|
146
|
+
return [
|
|
147
|
+
sys.executable, "-P", "-m", "manigent_daemon.cli",
|
|
148
|
+
"run", "--user", username,
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _login_path() -> str:
|
|
153
|
+
"""The PATH the login-started process should see: the installing shell's.
|
|
154
|
+
|
|
155
|
+
launchd and systemd start units with a bare system PATH
|
|
156
|
+
(``/usr/bin:/bin:/usr/sbin:/sbin``). ``_run_argv`` pins the interpreter so
|
|
157
|
+
the connection itself starts, but everything IT shells — ``manigent`` for
|
|
158
|
+
agent start/stop and the model-CLI check, and through that ``claude`` /
|
|
159
|
+
``codex`` in ``~/.local/bin`` or ``/opt/homebrew/bin`` — is found by PATH.
|
|
160
|
+
On a bare PATH the model check finds nothing and the page tells a working
|
|
161
|
+
user that no model is available. Recording the PATH of the shell that ran
|
|
162
|
+
``computer install`` is what makes the login process see what the user sees.
|
|
163
|
+
Re-running install (or ``autostart enable``) refreshes it.
|
|
164
|
+
"""
|
|
165
|
+
entries: list[str] = []
|
|
166
|
+
for entry in (
|
|
167
|
+
str(Path(sys.executable).parent),
|
|
168
|
+
str(Path.home() / ".local" / "bin"),
|
|
169
|
+
*os.environ.get("PATH", "").split(os.pathsep),
|
|
170
|
+
):
|
|
171
|
+
if entry and entry not in entries:
|
|
172
|
+
entries.append(entry)
|
|
173
|
+
return os.pathsep.join(entries)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# macOS — launchd
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _data_dir_tag() -> str:
|
|
182
|
+
"""A short, stable tag for a non-default data dir, else "".
|
|
183
|
+
|
|
184
|
+
``MANIGENT_DATA_DIR`` is how one machine keeps test, staging and prod side
|
|
185
|
+
by side, and the same username can exist in each. Without a tag their login
|
|
186
|
+
entries would share one name and the second install would replace the
|
|
187
|
+
first's. The default data dir gets no tag, so existing entries keep their
|
|
188
|
+
names and nothing needs migrating. ``manigent/doctor.py`` re-derives this.
|
|
189
|
+
"""
|
|
190
|
+
data_dir = cfg.data_dir().expanduser().resolve()
|
|
191
|
+
if data_dir == (Path.home() / ".manigent").resolve():
|
|
192
|
+
return ""
|
|
193
|
+
return hashlib.sha1(str(data_dir).encode()).hexdigest()[:8]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _login_env() -> dict[str, str]:
|
|
197
|
+
"""Environment for the login-started process.
|
|
198
|
+
|
|
199
|
+
Carries ``MANIGENT_DATA_DIR`` when it is not the default: launchd and
|
|
200
|
+
systemd start with a bare environment, so without it a connection set up
|
|
201
|
+
in another data dir would look in ``~/.manigent`` after a reboot and find
|
|
202
|
+
no key.
|
|
203
|
+
"""
|
|
204
|
+
env = {"PATH": _login_path()}
|
|
205
|
+
if _data_dir_tag():
|
|
206
|
+
env["MANIGENT_DATA_DIR"] = str(cfg.data_dir().expanduser().resolve())
|
|
207
|
+
return env
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _launchd_label(account: str) -> str:
|
|
211
|
+
label = f"{LABEL_PREFIX}.{account}" if account else LABEL_PREFIX
|
|
212
|
+
tag = _data_dir_tag()
|
|
213
|
+
return f"{label}.{tag}" if tag else label
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _launchd_path(account: str) -> Path:
|
|
217
|
+
return (
|
|
218
|
+
Path.home() / "Library" / "LaunchAgents" / f"{_launchd_label(account)}.plist"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _launchd_plist(account: str) -> dict:
|
|
223
|
+
"""The agent definition.
|
|
224
|
+
|
|
225
|
+
``RunAtLoad`` is the whole feature. ``KeepAlive`` is absent on purpose — see
|
|
226
|
+
the module docstring on why supervising would break ``computer stop``.
|
|
227
|
+
|
|
228
|
+
The two log paths are the SAME files the foreground and detached paths write
|
|
229
|
+
(``cfg.log_paths``), so "why did my computer stop last night" is one
|
|
230
|
+
``manigent computer logs`` regardless of who started it.
|
|
231
|
+
"""
|
|
232
|
+
stdout_log, stderr_log = cfg.log_paths()
|
|
233
|
+
return {
|
|
234
|
+
"Label": _launchd_label(account),
|
|
235
|
+
"ProgramArguments": _run_argv(account),
|
|
236
|
+
"EnvironmentVariables": _login_env(),
|
|
237
|
+
"RunAtLoad": True,
|
|
238
|
+
"ProcessType": "Background",
|
|
239
|
+
"StandardOutPath": str(stdout_log),
|
|
240
|
+
"StandardErrorPath": str(stderr_log),
|
|
241
|
+
"WorkingDirectory": str(Path.home()),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _launchctl_domain() -> str:
|
|
246
|
+
return f"gui/{os.getuid()}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _launchd_enable(account: str) -> AutostartState:
|
|
250
|
+
path = _launchd_path(account)
|
|
251
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
stdout_log, _ = cfg.log_paths()
|
|
253
|
+
stdout_log.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
with open(path, "wb") as fh:
|
|
255
|
+
plistlib.dump(_launchd_plist(account), fh)
|
|
256
|
+
|
|
257
|
+
# Replace rather than add: re-enabling after an upgrade must pick up the new
|
|
258
|
+
# interpreter path, and `bootstrap` refuses a label that is already loaded.
|
|
259
|
+
_run(["launchctl", "bootout", f"{_launchctl_domain()}/{_launchd_label(account)}"])
|
|
260
|
+
ok, message = _run(["launchctl", "bootstrap", _launchctl_domain(), str(path)])
|
|
261
|
+
if not ok:
|
|
262
|
+
# `bootstrap` arrived in macOS 10.11 and `load` still works everywhere;
|
|
263
|
+
# trying the old verb costs one call and covers older systems.
|
|
264
|
+
ok, message = _run(["launchctl", "load", "-w", str(path)])
|
|
265
|
+
if not ok:
|
|
266
|
+
return AutostartState(
|
|
267
|
+
supported=True,
|
|
268
|
+
installed=False,
|
|
269
|
+
detail=f"Could not register with macOS: {message}",
|
|
270
|
+
path=str(path),
|
|
271
|
+
)
|
|
272
|
+
return AutostartState(
|
|
273
|
+
supported=True,
|
|
274
|
+
installed=True,
|
|
275
|
+
detail="Will start when you log in",
|
|
276
|
+
path=str(path),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _launchd_disable(account: str) -> AutostartState:
|
|
281
|
+
path = _launchd_path(account)
|
|
282
|
+
_run(["launchctl", "bootout", f"{_launchctl_domain()}/{_launchd_label(account)}"])
|
|
283
|
+
_run(["launchctl", "unload", "-w", str(path)])
|
|
284
|
+
try:
|
|
285
|
+
path.unlink()
|
|
286
|
+
except FileNotFoundError:
|
|
287
|
+
pass
|
|
288
|
+
except OSError as e:
|
|
289
|
+
return AutostartState(
|
|
290
|
+
supported=True,
|
|
291
|
+
installed=True,
|
|
292
|
+
detail=f"Could not remove {path}: {e}",
|
|
293
|
+
path=str(path),
|
|
294
|
+
)
|
|
295
|
+
return AutostartState(
|
|
296
|
+
supported=True,
|
|
297
|
+
installed=False,
|
|
298
|
+
detail="Will no longer start when you log in",
|
|
299
|
+
path=str(path),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _launchd_status(account: str) -> AutostartState:
|
|
304
|
+
path = _launchd_path(account)
|
|
305
|
+
if not path.is_file():
|
|
306
|
+
return AutostartState(
|
|
307
|
+
supported=True,
|
|
308
|
+
installed=False,
|
|
309
|
+
detail="Not set to start when you log in",
|
|
310
|
+
path=str(path),
|
|
311
|
+
)
|
|
312
|
+
return AutostartState(
|
|
313
|
+
supported=True,
|
|
314
|
+
installed=True,
|
|
315
|
+
detail="Starts when you log in",
|
|
316
|
+
path=str(path),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Linux — systemd user units
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _systemd_unit(account: str) -> str:
|
|
326
|
+
name = f"{SYSTEMD_PREFIX}-{account}" if account else SYSTEMD_PREFIX
|
|
327
|
+
tag = _data_dir_tag()
|
|
328
|
+
return f"{name}-{tag}.service" if tag else f"{name}.service"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _systemd_path(account: str) -> Path:
|
|
332
|
+
return Path.home() / ".config" / "systemd" / "user" / _systemd_unit(account)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _systemd_text(account: str) -> str:
|
|
336
|
+
"""The unit file.
|
|
337
|
+
|
|
338
|
+
``Restart`` is absent for the reason given in the module docstring.
|
|
339
|
+
``WantedBy=default.target`` is what makes ``enable`` mean "at login".
|
|
340
|
+
|
|
341
|
+
The process writes its own logs, so output goes to the journal as well and
|
|
342
|
+
nothing here redirects it — a systemd user unit that writes to files AND the
|
|
343
|
+
journal duplicates every line.
|
|
344
|
+
"""
|
|
345
|
+
argv = " ".join(_shell_quote(a) for a in _run_argv(account))
|
|
346
|
+
return (
|
|
347
|
+
"[Unit]\n"
|
|
348
|
+
f"Description=Manigent — keep this computer connected ({account})\n"
|
|
349
|
+
"After=network-online.target\n"
|
|
350
|
+
"Wants=network-online.target\n"
|
|
351
|
+
"\n"
|
|
352
|
+
"[Service]\n"
|
|
353
|
+
"Type=simple\n"
|
|
354
|
+
f"ExecStart={argv}\n"
|
|
355
|
+
# Quoted whole, with `%` doubled: systemd expands `%` specifiers here.
|
|
356
|
+
+ "".join(
|
|
357
|
+
f'Environment="{key}={value.replace("%", "%%")}"\n'
|
|
358
|
+
for key, value in _login_env().items()
|
|
359
|
+
)
|
|
360
|
+
+ f"WorkingDirectory={Path.home()}\n"
|
|
361
|
+
"\n"
|
|
362
|
+
"[Install]\n"
|
|
363
|
+
"WantedBy=default.target\n"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _shell_quote(value: str) -> str:
|
|
368
|
+
"""Quote one systemd ``ExecStart`` argument.
|
|
369
|
+
|
|
370
|
+
systemd parses ExecStart itself rather than handing it to a shell, and it
|
|
371
|
+
understands double quotes. Only paths with spaces need it, which is a real
|
|
372
|
+
case (``/Users/Some Name/...``).
|
|
373
|
+
"""
|
|
374
|
+
return f'"{value}"' if " " in value else value
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _systemd_enable(account: str) -> AutostartState:
|
|
378
|
+
if not shutil.which("systemctl"):
|
|
379
|
+
return AutostartState(
|
|
380
|
+
supported=False,
|
|
381
|
+
installed=False,
|
|
382
|
+
detail="This Linux has no systemd, so there is nothing to register with",
|
|
383
|
+
)
|
|
384
|
+
path = _systemd_path(account)
|
|
385
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
path.write_text(_systemd_text(account))
|
|
387
|
+
|
|
388
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
389
|
+
ok, message = _run(["systemctl", "--user", "enable", _systemd_unit(account)])
|
|
390
|
+
if not ok:
|
|
391
|
+
return AutostartState(
|
|
392
|
+
supported=True,
|
|
393
|
+
installed=False,
|
|
394
|
+
detail=f"Could not register with systemd: {message}",
|
|
395
|
+
path=str(path),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Without lingering, a user unit is torn down at logout — so on a machine
|
|
399
|
+
# nobody sits in front of, "starts at login" would never happen. Best-effort:
|
|
400
|
+
# it needs a polkit yes on some distros, and failing it still leaves a unit
|
|
401
|
+
# that works for a logged-in desktop user.
|
|
402
|
+
linger_ok, _ = _run(["loginctl", "enable-linger", os.environ.get("USER", "")])
|
|
403
|
+
detail = "Will start when you log in"
|
|
404
|
+
if not linger_ok:
|
|
405
|
+
detail += " (while you are logged in — `loginctl enable-linger` to cover reboots with no login)"
|
|
406
|
+
return AutostartState(
|
|
407
|
+
supported=True, installed=True, detail=detail, path=str(path)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _systemd_disable(account: str) -> AutostartState:
|
|
412
|
+
path = _systemd_path(account)
|
|
413
|
+
_run(["systemctl", "--user", "disable", _systemd_unit(account)])
|
|
414
|
+
try:
|
|
415
|
+
path.unlink()
|
|
416
|
+
except FileNotFoundError:
|
|
417
|
+
pass
|
|
418
|
+
except OSError as e:
|
|
419
|
+
return AutostartState(
|
|
420
|
+
supported=True,
|
|
421
|
+
installed=True,
|
|
422
|
+
detail=f"Could not remove {path}: {e}",
|
|
423
|
+
path=str(path),
|
|
424
|
+
)
|
|
425
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
426
|
+
return AutostartState(
|
|
427
|
+
supported=True,
|
|
428
|
+
installed=False,
|
|
429
|
+
detail="Will no longer start when you log in",
|
|
430
|
+
path=str(path),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _systemd_status(account: str) -> AutostartState:
|
|
435
|
+
if not shutil.which("systemctl"):
|
|
436
|
+
return AutostartState(
|
|
437
|
+
supported=False,
|
|
438
|
+
installed=False,
|
|
439
|
+
detail="This Linux has no systemd, so there is nothing to register with",
|
|
440
|
+
)
|
|
441
|
+
path = _systemd_path(account)
|
|
442
|
+
if not path.is_file():
|
|
443
|
+
return AutostartState(
|
|
444
|
+
supported=True,
|
|
445
|
+
installed=False,
|
|
446
|
+
detail="Not set to start when you log in",
|
|
447
|
+
path=str(path),
|
|
448
|
+
)
|
|
449
|
+
# The FILE existing is not the same as the unit being enabled — a user can
|
|
450
|
+
# `systemctl --user disable` without deleting it, and reporting that as
|
|
451
|
+
# enabled would be the checklist lying.
|
|
452
|
+
ok, _ = _run(["systemctl", "--user", "is-enabled", _systemd_unit(account)])
|
|
453
|
+
return AutostartState(
|
|
454
|
+
supported=True,
|
|
455
|
+
installed=ok,
|
|
456
|
+
detail="Starts when you log in" if ok else "Registered but switched off",
|
|
457
|
+
path=str(path),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
# The platform-agnostic surface
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
_UNSUPPORTED = (
|
|
467
|
+
"Starting at login is not something Manigent sets up on this platform yet. "
|
|
468
|
+
"Run `manigent computer start` after a restart."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def supported() -> bool:
|
|
473
|
+
return sys.platform in ("darwin", "linux")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def enable(username: str = "") -> AutostartState:
|
|
477
|
+
"""Register this account's connection to start at login. Never raises."""
|
|
478
|
+
account = _account(username)
|
|
479
|
+
if not account:
|
|
480
|
+
return AutostartState(
|
|
481
|
+
supported=supported(),
|
|
482
|
+
installed=False,
|
|
483
|
+
detail="No account is selected, so there is nothing to start at login",
|
|
484
|
+
)
|
|
485
|
+
if sys.platform == "darwin":
|
|
486
|
+
return _launchd_enable(account)
|
|
487
|
+
if sys.platform == "linux":
|
|
488
|
+
return _systemd_enable(account)
|
|
489
|
+
return AutostartState(supported=False, installed=False, detail=_UNSUPPORTED)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def disable(username: str = "") -> AutostartState:
|
|
493
|
+
"""Stop starting this account's connection at login. Never raises.
|
|
494
|
+
|
|
495
|
+
Does NOT stop a connection that is running now — the two are separate
|
|
496
|
+
decisions, and silently disconnecting a working machine because the user
|
|
497
|
+
asked about next Tuesday would be the wrong reading of the request.
|
|
498
|
+
"""
|
|
499
|
+
account = _account(username)
|
|
500
|
+
if sys.platform == "darwin":
|
|
501
|
+
return _launchd_disable(account)
|
|
502
|
+
if sys.platform == "linux":
|
|
503
|
+
return _systemd_disable(account)
|
|
504
|
+
return AutostartState(supported=False, installed=False, detail=_UNSUPPORTED)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def status(username: str = "") -> AutostartState:
|
|
508
|
+
"""Is it registered? Never raises, never changes anything."""
|
|
509
|
+
account = _account(username)
|
|
510
|
+
if sys.platform == "darwin":
|
|
511
|
+
return _launchd_status(account)
|
|
512
|
+
if sys.platform == "linux":
|
|
513
|
+
return _systemd_status(account)
|
|
514
|
+
return AutostartState(supported=False, installed=False, detail=_UNSUPPORTED)
|