sessionmail 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {sessionmail-0.2.0 → sessionmail-0.2.1}/.claude-plugin/plugin.json +1 -1
- {sessionmail-0.2.0 → sessionmail-0.2.1}/PKG-INFO +1 -11
- {sessionmail-0.2.0 → sessionmail-0.2.1}/README.md +0 -10
- {sessionmail-0.2.0 → sessionmail-0.2.1}/scripts/seat-agent/README.md +12 -4
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_birth.py +28 -5
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_drop.py +14 -7
- sessionmail-0.2.1/src/sessionmail/_launchd.py +426 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_magicdns.py +52 -4
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_mhs.py +20 -2
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_render.py +6 -3
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_seat.py +63 -1
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_sig.py +13 -5
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_voice.py +1 -1
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/message.py +40 -15
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-run +11 -8
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/pager.py +12 -2
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/seat/seat-agent.plist.template +4 -3
- {sessionmail-0.2.0 → sessionmail-0.2.1}/.gitignore +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/LICENSE +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/hooks/mail-guard/README.md +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/plugins/pager-monitor/README.md +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/pyproject.toml +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/__init__.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_archive.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_arm.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_boxes.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_detect.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_detect_imap.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_generator.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_headers.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_help.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_index.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_init.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_injectlog.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_layer.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_maildir.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_mcp.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_metrics.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_migrate.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_notmuch.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_operator.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_parsers.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_pull_imap.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_reach.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_record.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_root.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_say.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_send.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_sender.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_settings.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_setup.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_shorten.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_stub.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_subscribe.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_subscription.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_tokens.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_views.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_why.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/arm.txt +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/hook_lines.toml +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/humans +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mail.schema.json +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/memory.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/mail-box.lib.sh +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/mail-ext.lib.sh +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/mhs-thread +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-cat +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-ls +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-tree +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-verify +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-wake +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/notmuch-config.sessionkit.template +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/notmuch-config.store.template +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/post-new +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/store-index +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/delivery-check +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/magicdns-suffix +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/nm-deliver +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/vmailbox-gen +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/rules.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/seat/seat-agent +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/seat_init.py +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/settings.template.json +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/tests/fixtures/sig_v2/README.md +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/tests/fixtures/sm827_hint_root/README.md +0 -0
- {sessionmail-0.2.0 → sessionmail-0.2.1}/tests/fixtures/sm827_honest/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sessionmail
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: sessionmail - mail for coding agents: message, pager and memory over one maildir.
|
|
5
5
|
Project-URL: Homepage, https://github.com/sessionkit/sessionmail
|
|
6
6
|
Project-URL: Repository, https://github.com/sessionkit/sessionmail
|
|
@@ -104,16 +104,6 @@ message setup # again: it links the notmuch hook
|
|
|
104
104
|
- **Nothing runs `notmuch new` for you.** `message mark` and `memory`
|
|
105
105
|
read an empty index until something does: launchd, cron, or by hand.
|
|
106
106
|
|
|
107
|
-
**Status.** `sessionmail` 0.0.1 on PyPI is a name reservation, not this
|
|
108
|
-
code - a 1.3 KB placeholder with no dependencies.
|
|
109
|
-
Until 0.2.0 ships, install from source instead of the `uv tool install
|
|
110
|
-
sessionmail` line above:
|
|
111
|
-
|
|
112
|
-
```sh
|
|
113
|
-
uv tool install git+https://github.com/sessionkit/sessionmail # from the repo
|
|
114
|
-
make install # from a clone
|
|
115
|
-
```
|
|
116
|
-
|
|
117
107
|
`message`, `pager` and `memory` all run; the specs, in the git repository
|
|
118
108
|
(the release archive leaves them out), say what each verb is meant to do
|
|
119
109
|
and `make progress` counts how much of it is built.
|
|
@@ -85,16 +85,6 @@ message setup # again: it links the notmuch hook
|
|
|
85
85
|
- **Nothing runs `notmuch new` for you.** `message mark` and `memory`
|
|
86
86
|
read an empty index until something does: launchd, cron, or by hand.
|
|
87
87
|
|
|
88
|
-
**Status.** `sessionmail` 0.0.1 on PyPI is a name reservation, not this
|
|
89
|
-
code - a 1.3 KB placeholder with no dependencies.
|
|
90
|
-
Until 0.2.0 ships, install from source instead of the `uv tool install
|
|
91
|
-
sessionmail` line above:
|
|
92
|
-
|
|
93
|
-
```sh
|
|
94
|
-
uv tool install git+https://github.com/sessionkit/sessionmail # from the repo
|
|
95
|
-
make install # from a clone
|
|
96
|
-
```
|
|
97
|
-
|
|
98
88
|
`message`, `pager` and `memory` all run; the specs, in the git repository
|
|
99
89
|
(the release archive leaves them out), say what each verb is meant to do
|
|
100
90
|
and `make progress` counts how much of it is built.
|
|
@@ -9,6 +9,8 @@ through `message`, which keeps the package at three commands:
|
|
|
9
9
|
|
|
10
10
|
message seat init --registry DIR ADDRESS mint the seat's key (was scripts/seat-init)
|
|
11
11
|
message seat agent ADDRESS [SOCKET] run its agent (was scripts/seat-agent/seat-agent)
|
|
12
|
+
message seat agent --install ADDRESS [SOCKET] render and load the LaunchAgent
|
|
13
|
+
message seat agent --uninstall ADDRESS unload it and remove its plist
|
|
12
14
|
message seat agent --template print the LaunchAgent template
|
|
13
15
|
|
|
14
16
|
The `seat` noun is hidden from `message --help` and from the MCP tools; `message
|
|
@@ -48,8 +50,14 @@ seat --help` answers when typed.
|
|
|
48
50
|
|
|
49
51
|
## The template
|
|
50
52
|
|
|
53
|
+
`message seat agent --install ADDRESS [SOCKET]` renders it, writes it into
|
|
54
|
+
`~/Library/LaunchAgents`, loads it with `launchctl bootstrap`, and checks that the agent
|
|
55
|
+
answers. `--uninstall ADDRESS` boots it out and removes the plist. Their contract
|
|
56
|
+
(output, exits, the `Capability: install` marker) is in
|
|
57
|
+
[`docs/message-seat.md`](../../docs/message-seat.md).
|
|
58
|
+
|
|
51
59
|
`message seat agent --template` prints it, and nothing else: it never renders it, loads
|
|
52
|
-
it or runs launchctl.
|
|
60
|
+
it or runs launchctl. To render it by hand, replace each `@PLACEHOLDER@`, then lint it:
|
|
53
61
|
|
|
54
62
|
message seat agent --template |
|
|
55
63
|
sed -e 's|@ADDRESS@|bot.alice|g' -e "s|@MESSAGE@|$(command -v message)|g" \
|
|
@@ -68,9 +76,9 @@ ProgramArguments renders to `message seat agent ADDRESS SOCKET`. The placeholder
|
|
|
68
76
|
On a host that runs `message` from an editable checkout, merging there changes the
|
|
69
77
|
seat's agent at its next start.
|
|
70
78
|
|
|
71
|
-
**
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
**No test and no make target loads a job on the real launchd.** The suite runs
|
|
80
|
+
`--install` against a fake `launchctl` (`SESSIONMAIL_LAUNCHCTL`). A live install is the
|
|
81
|
+
host operator's act: `message seat agent --install`, or the hand render above.
|
|
74
82
|
|
|
75
83
|
## The adapter's env (an example; the adapter owns it)
|
|
76
84
|
|
|
@@ -68,6 +68,19 @@ SUBSCRIBE_VAR = "SESSIONMAIL_SUBSCRIBE"
|
|
|
68
68
|
#: one: the detached generator's line.
|
|
69
69
|
BORN_FILE = "born"
|
|
70
70
|
|
|
71
|
+
#: What a generator line says on a mail-drop host, suffix or none: the drop
|
|
72
|
+
#: builds no maps, so no generator runs there (`_seat._run_generator`'s own
|
|
73
|
+
#: words for it; `tests/test_birth.py` holds the two equal).
|
|
74
|
+
DROP_REGEN = "generator not run: the carrier is the mail drop; it reads the maps per send"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _on_drop(env: Mapping[str, str] | None) -> bool:
|
|
78
|
+
"""Whether the carrier is the mail drop. Decides what a generator line
|
|
79
|
+
SAYS, never what runs."""
|
|
80
|
+
from . import _settings
|
|
81
|
+
return _settings.carrier(env).kind == "drop"
|
|
82
|
+
|
|
83
|
+
|
|
71
84
|
#: Who births when the caller does not say: the subscription journal's `argv`.
|
|
72
85
|
VIA_LISTEN = "pager listen"
|
|
73
86
|
|
|
@@ -350,13 +363,19 @@ def ensure_session_box(session: str, seat: "Seat | None", *,
|
|
|
350
363
|
f"{stale}, left by an earlier run of this session; its row "
|
|
351
364
|
f"goes at the next regen")
|
|
352
365
|
if has_session and seat is not None:
|
|
353
|
-
|
|
366
|
+
if detach and _settings.carrier(env).kind == "drop":
|
|
367
|
+
# The drop's doorbell runs the generator (`_drop.prepare`), and a
|
|
368
|
+
# detaching call never waits on it; the listen asks instead.
|
|
369
|
+
live, why = None, "not measured at session start; the first pager listen measures it"
|
|
370
|
+
else:
|
|
371
|
+
live, why = _seat.doorbell(seat.address, session, env)
|
|
354
372
|
regen_said = ""
|
|
355
373
|
if (birth.born or live is False) and detach:
|
|
356
374
|
_detach(birth, session, env, root, record_source)
|
|
357
375
|
regen_said = birth.regen_line
|
|
358
376
|
elif (birth.born or live is False) and birth.suffix_unknown:
|
|
359
|
-
regen_said =
|
|
377
|
+
regen_said = (DROP_REGEN if _on_drop(env)
|
|
378
|
+
else "generator NOT run (MagicDNS suffix unknown)")
|
|
360
379
|
elif birth.born or live is False:
|
|
361
380
|
regen_said = _seat.regen_maps(root, env=env)
|
|
362
381
|
birth.regen, birth.regen_line = "ran", regen_said
|
|
@@ -384,7 +403,8 @@ def ensure_session_box(session: str, seat: "Seat | None", *,
|
|
|
384
403
|
# included: the whole generator, detached, routes the boxes too.
|
|
385
404
|
_detach(birth, session, env, root, record_source)
|
|
386
405
|
elif owed and birth.suffix_unknown:
|
|
387
|
-
birth.sub_lines.append(" map :
|
|
406
|
+
birth.sub_lines.append(f" map : {DROP_REGEN}" if _on_drop(env) else
|
|
407
|
+
" map : NOT rebuilt - MagicDNS suffix unknown; the "
|
|
388
408
|
"subscriptions stand, and the next rebuild with a suffix "
|
|
389
409
|
"routes them")
|
|
390
410
|
elif owed:
|
|
@@ -429,8 +449,11 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
429
449
|
# The hook did not ask for the suffix (it costs the hook's
|
|
430
450
|
# budget), so the child asks, and never runs the generator without
|
|
431
451
|
# one: the record's line 2 says so instead.
|
|
452
|
+
# On a mail-drop host no generator runs either way, so the line
|
|
453
|
+
# names the carrier, not the suffix.
|
|
432
454
|
suffix, why = _magicdns.lookup()
|
|
433
455
|
line = (_seat.regen_maps(Path(args.regen)) if suffix is not None
|
|
456
|
+
else DROP_REGEN if _on_drop(None)
|
|
434
457
|
else f"generator NOT run (MagicDNS suffix unknown: {why})")
|
|
435
458
|
except Exception as exc: # noqa: BLE001 - regen_maps never raises; a child that dies says why
|
|
436
459
|
line = f"generator failed in the background: {exc}"
|
|
@@ -443,8 +466,8 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
443
466
|
return 0
|
|
444
467
|
|
|
445
468
|
|
|
446
|
-
__all__ = ["BORN_FILE", "SUBSCRIBE_VAR", "Birth", "BornRecord", "birth_subscriptions",
|
|
447
|
-
"detach_regen", "ensure_session_box", "read_born"]
|
|
469
|
+
__all__ = ["BORN_FILE", "DROP_REGEN", "SUBSCRIBE_VAR", "Birth", "BornRecord", "birth_subscriptions",
|
|
470
|
+
"born_record", "detach_regen", "ensure_session_box", "read_born"]
|
|
448
471
|
|
|
449
472
|
|
|
450
473
|
if __name__ == "__main__":
|
|
@@ -132,6 +132,11 @@ class Maps:
|
|
|
132
132
|
#: The domain a bare From is qualified with (`origin`); set by the
|
|
133
133
|
#: sender's drop pre-flight, empty when nothing asked.
|
|
134
134
|
origin: str = ""
|
|
135
|
+
#: Why the generator refused the alias print (`generator exit N: its
|
|
136
|
+
#: last stderr line`), set by `prepare`; empty when it printed. The
|
|
137
|
+
#: alias map is then empty, and a reader that must tell "no row" from
|
|
138
|
+
#: "no map" (the doorbell) reads this.
|
|
139
|
+
alias_refused: str = ""
|
|
135
140
|
|
|
136
141
|
|
|
137
142
|
def selected(env: Mapping[str, str]) -> bool:
|
|
@@ -183,16 +188,18 @@ def origin(env: Mapping[str, str], host: str, notes: list[str] | None = None) ->
|
|
|
183
188
|
- Else the FQDN from the ONE suffix definition (`_magicdns`), the form
|
|
184
189
|
postfix writes when `myorigin = $myhostname`, and a form
|
|
185
190
|
`_voice.host_forms` accepts.
|
|
186
|
-
- Else the short host
|
|
187
|
-
no
|
|
188
|
-
|
|
191
|
+
- Else the short host. A host with no tailnet gets here on every send
|
|
192
|
+
(the generator's no-fqdn rows), and there the short host is right, so
|
|
193
|
+
nothing is said (`_magicdns.drop_without_tailnet`). Any other host
|
|
194
|
+
(a malformed operator suffix, or a tailscale that is there but does
|
|
195
|
+
not answer) is said in NOTES."""
|
|
189
196
|
given = (env.get(ORIGIN_VAR) or "").strip() or _settings.file_text("postfix.origin", env)
|
|
190
197
|
if given:
|
|
191
198
|
return given
|
|
192
199
|
found, _why = _magicdns.lookup(env)
|
|
193
200
|
if found:
|
|
194
201
|
return f"{host}.{found}"
|
|
195
|
-
if notes is not None:
|
|
202
|
+
if notes is not None and not _magicdns.drop_without_tailnet(env):
|
|
196
203
|
notes.append(f"⚠ {_magicdns.UNKNOWN} - a bare From is qualified with the short "
|
|
197
204
|
f"host only (@{host})")
|
|
198
205
|
return host
|
|
@@ -308,9 +315,9 @@ def prepare(env: Mapping[str, str]) -> Maps:
|
|
|
308
315
|
if done.returncode == 0:
|
|
309
316
|
maps.alias = _rows(done.stdout)
|
|
310
317
|
else:
|
|
311
|
-
maps.
|
|
312
|
-
|
|
313
|
-
"only; no session box gets its doorbell copy")
|
|
318
|
+
maps.alias_refused = f"generator exit {done.returncode}: {_last(done.stderr)}"
|
|
319
|
+
maps.notes.append(f"⚠ no alias map ({maps.alias_refused}) - seat mail lands in the "
|
|
320
|
+
"seat's own box only; no session box gets its doorbell copy")
|
|
314
321
|
return maps
|
|
315
322
|
|
|
316
323
|
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""The seat agent's LaunchAgent: render it, load it, unload it.
|
|
2
|
+
|
|
3
|
+
`message seat agent --install ADDRESS [SOCKET]` and `--uninstall ADDRESS`.
|
|
4
|
+
A seat with no Claude session (a bot) signs through its own ssh-agent, and
|
|
5
|
+
that agent has to come back after a reboot: launchd keeps it alive. This
|
|
6
|
+
module renders the packaged template (`seat/seat-agent.plist.template`),
|
|
7
|
+
writes it into `~/Library/LaunchAgents`, loads it with `launchctl
|
|
8
|
+
bootstrap`, and checks the effect: the agent answers on its socket and
|
|
9
|
+
holds seat.pub's key. `--uninstall` boots the job out and removes the plist.
|
|
10
|
+
It never touches the key, the registry or the cert.
|
|
11
|
+
|
|
12
|
+
Streams and exits follow `seat init`'s convention:
|
|
13
|
+
|
|
14
|
+
- stdout: the success report only (`--install`'s KEY=VALUE lines).
|
|
15
|
+
- stderr: narration, refusals (`REFUSED - ... Nothing was written.`) and
|
|
16
|
+
failures (`FAILED - ...`, naming what was put back).
|
|
17
|
+
- 0 done · 1 launchctl failed, a plist write or removal failed, or the
|
|
18
|
+
agent never answered, rolled back · 2 usage or refused, nothing written.
|
|
19
|
+
|
|
20
|
+
`launchctl` is `$SESSIONMAIL_LAUNCHCTL` when set and not empty, else
|
|
21
|
+
`/bin/launchctl`: the same seam as `SESSIONMAIL_SENDMAIL`. The test suite
|
|
22
|
+
points it at a fake for every test, so no test reaches the real launchd.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import plistlib
|
|
29
|
+
import re
|
|
30
|
+
import shlex
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import IO, Mapping
|
|
38
|
+
from xml.sax.saxutils import escape
|
|
39
|
+
|
|
40
|
+
PROG = "message seat agent"
|
|
41
|
+
|
|
42
|
+
EX_OK = 0
|
|
43
|
+
EX_FAILED = 1
|
|
44
|
+
EX_REFUSED = 2
|
|
45
|
+
|
|
46
|
+
#: The seam: a launchctl of the caller's choosing (a test's fake).
|
|
47
|
+
LAUNCHCTL_VAR = "SESSIONMAIL_LAUNCHCTL"
|
|
48
|
+
DEFAULT_LAUNCHCTL = "/bin/launchctl"
|
|
49
|
+
|
|
50
|
+
#: The kit's launchd family for mail, as the template's Label has it.
|
|
51
|
+
LABEL_PREFIX = "dev.sessionkit.mail.seat-agent."
|
|
52
|
+
#: The wrapper's own rules (seat/seat-agent): the address, and the socket
|
|
53
|
+
#: cap. macOS's sun_path is 104 bytes with its NUL.
|
|
54
|
+
ADDRESS_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
55
|
+
SOCK_MAX = 103
|
|
56
|
+
PLACEHOLDER = re.compile(r"@([A-Z_]+)@")
|
|
57
|
+
|
|
58
|
+
#: How long the agent has to answer after a load, and the job to go after
|
|
59
|
+
#: a bootout. Module values, so a test can shorten them in-process.
|
|
60
|
+
ANSWER_SECONDS = 10.0
|
|
61
|
+
GONE_SECONDS = 5.0
|
|
62
|
+
POLL_SECONDS = 0.1
|
|
63
|
+
#: How many lines of the job's log a failure quotes.
|
|
64
|
+
LOG_TAIL = 8
|
|
65
|
+
|
|
66
|
+
#: The packaged template, beside this module's real path.
|
|
67
|
+
TEMPLATE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "seat",
|
|
68
|
+
"seat-agent.plist.template")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Refused(Exception):
|
|
72
|
+
"""Exit 2: usage or refused, nothing written."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Failed(Exception):
|
|
76
|
+
"""Exit 1: a tool failed; the message names what was put back."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Job:
|
|
81
|
+
address: str
|
|
82
|
+
label: str
|
|
83
|
+
plist: str # ~/Library/LaunchAgents/<label>.plist
|
|
84
|
+
socket: str
|
|
85
|
+
pub: str
|
|
86
|
+
key: str
|
|
87
|
+
log: str # <log_dir>/seat-agent.<address>.log
|
|
88
|
+
log_dir: str
|
|
89
|
+
sessionmail_home: str
|
|
90
|
+
message: str # `command -v message`, not resolved
|
|
91
|
+
domain: str # gui/<uid>
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def target(self) -> str:
|
|
95
|
+
return f"{self.domain}/{self.label}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def label_for(address: str) -> str:
|
|
99
|
+
return LABEL_PREFIX + address
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def launchctl_path(env: Mapping[str, str]) -> str:
|
|
103
|
+
return env.get(LAUNCHCTL_VAR) or DEFAULT_LAUNCHCTL
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def launchctl(env: Mapping[str, str], *args: str) -> subprocess.CompletedProcess:
|
|
107
|
+
"""Run launchctl ARGS (the seam's, else /bin/launchctl), output captured.
|
|
108
|
+
A launchctl that cannot be started is a Failed, before anything moves."""
|
|
109
|
+
path = launchctl_path(env)
|
|
110
|
+
try:
|
|
111
|
+
return subprocess.run([path, *args], capture_output=True, text=True,
|
|
112
|
+
stdin=subprocess.DEVNULL, env=dict(env), check=False)
|
|
113
|
+
except OSError as exc:
|
|
114
|
+
raise Failed(f"launchctl could not run ({path}: {exc.strerror or exc})") from exc
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _words(done: subprocess.CompletedProcess) -> str:
|
|
118
|
+
said = " ".join((done.stderr or done.stdout or "").split())
|
|
119
|
+
return f"rc {done.returncode}" + (f": {said}" if said else "")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sessionmail_home(env: Mapping[str, str], home: str) -> str:
|
|
123
|
+
"""As the wrapper reads it: $SESSIONMAIL_HOME, a leading ~ expanded, else
|
|
124
|
+
$HOME/.sessionmail. Absolute, as the template wants it."""
|
|
125
|
+
value = env.get("SESSIONMAIL_HOME") or os.path.join(home, ".sessionmail")
|
|
126
|
+
if value == "~" or value.startswith("~/"):
|
|
127
|
+
value = home + value[1:]
|
|
128
|
+
return os.path.abspath(value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _check_platform() -> None:
|
|
132
|
+
if sys.platform != "darwin":
|
|
133
|
+
raise Refused(f"launchd only: this is {sys.platform}, not macOS; run "
|
|
134
|
+
f"`{PROG} ADDRESS [SOCKET]` under your own service manager")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_address(address: str) -> None:
|
|
138
|
+
if "+" in address:
|
|
139
|
+
raise Refused(f"'{address}' has a '+': a seat is named with a dot (bot.alice), "
|
|
140
|
+
"as `message seat init` mints it")
|
|
141
|
+
if not ADDRESS_RE.match(address):
|
|
142
|
+
raise Refused(f"'{address}' is not a seat address")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _home(env: Mapping[str, str]) -> str:
|
|
146
|
+
home = env.get("HOME", "")
|
|
147
|
+
if not os.path.isabs(home):
|
|
148
|
+
raise Refused("HOME is unset or not absolute: the LaunchAgent lives under "
|
|
149
|
+
"$HOME/Library/LaunchAgents")
|
|
150
|
+
return home
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def plan(address: str, socket: str | None, env: Mapping[str, str]) -> Job:
|
|
154
|
+
"""The job for ADDRESS, every install check made; nothing is written."""
|
|
155
|
+
_check_platform()
|
|
156
|
+
_check_address(address)
|
|
157
|
+
home = _home(env)
|
|
158
|
+
sm_home = _sessionmail_home(env, home)
|
|
159
|
+
key_home = os.path.join(sm_home, "seat-keys", address)
|
|
160
|
+
key = os.path.join(key_home, "id_ed25519")
|
|
161
|
+
pub = os.path.join(key_home, "seat.pub")
|
|
162
|
+
if not os.path.isdir(key_home):
|
|
163
|
+
raise Refused(f"no key home {key_home}: mint the seat first "
|
|
164
|
+
f"(message seat init --registry DIR {address})")
|
|
165
|
+
for path in (key, pub):
|
|
166
|
+
if not os.path.isfile(path):
|
|
167
|
+
raise Refused(f"no {os.path.basename(path)} at {path}: mint the seat first "
|
|
168
|
+
f"(message seat init --registry DIR {address})")
|
|
169
|
+
if socket is None:
|
|
170
|
+
socket = os.path.join(key_home, "agent.sock")
|
|
171
|
+
elif not os.path.isabs(socket):
|
|
172
|
+
raise Refused(f"SOCKET {socket!r} is not an absolute path: launchd starts the "
|
|
173
|
+
"job in /, so name it in full")
|
|
174
|
+
size = len(socket.encode())
|
|
175
|
+
if size > SOCK_MAX:
|
|
176
|
+
raise Refused(f"socket path is {size} bytes; macOS binds {SOCK_MAX} at most "
|
|
177
|
+
"(104 with its NUL). Name a shorter SOCKET")
|
|
178
|
+
if not os.path.isdir(os.path.dirname(socket)):
|
|
179
|
+
raise Refused(f"no directory {os.path.dirname(socket)} for the socket")
|
|
180
|
+
message = shutil.which("message", path=env["PATH"]) if env.get("PATH") else None
|
|
181
|
+
if message is None:
|
|
182
|
+
raise Refused("`message` is not on PATH: launchd runs `message seat agent`, "
|
|
183
|
+
"by the path `command -v message` prints")
|
|
184
|
+
label = label_for(address)
|
|
185
|
+
log_dir = os.path.join(home, "Library", "Logs")
|
|
186
|
+
return Job(address=address, label=label,
|
|
187
|
+
plist=os.path.join(home, "Library", "LaunchAgents", f"{label}.plist"),
|
|
188
|
+
socket=socket, pub=pub, key=key,
|
|
189
|
+
log=os.path.join(log_dir, f"seat-agent.{address}.log"), log_dir=log_dir,
|
|
190
|
+
sessionmail_home=sm_home, message=os.path.abspath(message),
|
|
191
|
+
domain=f"gui/{os.getuid()}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def render(template: bytes, job: Job) -> bytes:
|
|
195
|
+
"""The template with its five placeholders filled, each value XML-escaped.
|
|
196
|
+
One pass, so a value is never itself read for a placeholder. An unknown
|
|
197
|
+
placeholder, a render plistlib cannot read, or a Label that is not the
|
|
198
|
+
job's is refused."""
|
|
199
|
+
values = {"ADDRESS": job.address, "MESSAGE": job.message,
|
|
200
|
+
"SESSIONMAIL_HOME": job.sessionmail_home, "SOCKET": job.socket,
|
|
201
|
+
"LOG_DIR": job.log_dir}
|
|
202
|
+
text = template.decode("utf-8")
|
|
203
|
+
unknown = sorted(set(PLACEHOLDER.findall(text)) - set(values))
|
|
204
|
+
if unknown:
|
|
205
|
+
raise Refused("the packaged template holds a placeholder this sessionmail does not "
|
|
206
|
+
f"fill ({', '.join('@' + u + '@' for u in unknown)}): a broken install")
|
|
207
|
+
out = PLACEHOLDER.sub(lambda m: escape(values[m.group(1)]), text).encode("utf-8")
|
|
208
|
+
try:
|
|
209
|
+
parsed = plistlib.loads(out)
|
|
210
|
+
except Exception as exc: # plistlib raises several kinds on a bad document
|
|
211
|
+
raise Refused(f"the rendered LaunchAgent is not a valid plist ({exc})") from exc
|
|
212
|
+
if parsed.get("Label") != job.label:
|
|
213
|
+
raise Refused(f"the rendered Label is {parsed.get('Label')!r}, not {job.label!r}")
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def loaded(job_target: str, env: Mapping[str, str]) -> bool:
|
|
218
|
+
"""`launchctl print TARGET`: rc 0 means the job is loaded."""
|
|
219
|
+
return launchctl(env, "print", job_target).returncode == 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _read(path: str) -> bytes | None:
|
|
223
|
+
try:
|
|
224
|
+
with open(path, "rb") as f:
|
|
225
|
+
return f.read()
|
|
226
|
+
except FileNotFoundError:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _write(path: str, data: bytes) -> None:
|
|
231
|
+
"""Write DATA at PATH, 0644, through a temp file and os.replace."""
|
|
232
|
+
os.makedirs(os.path.dirname(path), mode=0o755, exist_ok=True)
|
|
233
|
+
fd, tmp = tempfile.mkstemp(prefix=".seat-agent.", dir=os.path.dirname(path))
|
|
234
|
+
try:
|
|
235
|
+
with os.fdopen(fd, "wb") as f:
|
|
236
|
+
f.write(data)
|
|
237
|
+
os.chmod(tmp, 0o644)
|
|
238
|
+
os.replace(tmp, path)
|
|
239
|
+
except BaseException:
|
|
240
|
+
if os.path.exists(tmp):
|
|
241
|
+
os.unlink(tmp)
|
|
242
|
+
raise
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def answers(job: Job, env: Mapping[str, str]) -> bool:
|
|
246
|
+
"""The agent on the socket lists seat.pub's key. A clean env: only this
|
|
247
|
+
socket, never the caller's SSH_AUTH_SOCK."""
|
|
248
|
+
ssh_add = shutil.which("ssh-add", path=env.get("PATH", "")) or "/usr/bin/ssh-add"
|
|
249
|
+
try:
|
|
250
|
+
with open(job.pub) as f:
|
|
251
|
+
blob = f.read().split()[1]
|
|
252
|
+
except (OSError, IndexError):
|
|
253
|
+
return False
|
|
254
|
+
clean = {"PATH": "/usr/bin:/bin", "HOME": env.get("HOME", "/"), "SSH_AUTH_SOCK": job.socket}
|
|
255
|
+
try:
|
|
256
|
+
done = subprocess.run([ssh_add, "-L"], capture_output=True, text=True, env=clean,
|
|
257
|
+
stdin=subprocess.DEVNULL, timeout=5, check=False)
|
|
258
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
259
|
+
return False
|
|
260
|
+
return done.returncode == 0 and blob in done.stdout
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _wait(predicate, seconds: float) -> bool:
|
|
264
|
+
deadline = time.monotonic() + seconds
|
|
265
|
+
while True:
|
|
266
|
+
if predicate():
|
|
267
|
+
return True
|
|
268
|
+
if time.monotonic() >= deadline:
|
|
269
|
+
return False
|
|
270
|
+
time.sleep(POLL_SECONDS)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _log_tail(job: Job) -> str:
|
|
274
|
+
try:
|
|
275
|
+
with open(job.log, errors="replace") as f:
|
|
276
|
+
lines = f.read().splitlines()[-LOG_TAIL:]
|
|
277
|
+
except OSError:
|
|
278
|
+
return f"(no log at {job.log})"
|
|
279
|
+
return f"the last lines of {job.log}:\n" + "\n".join(" " + line for line in lines)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def report(job: Job) -> str:
|
|
283
|
+
"""The stdout contract: KEY=VALUE, shell-quoted, for `eval`."""
|
|
284
|
+
pairs = (("SEAT_LABEL", job.label), ("SEAT_PLIST", job.plist), ("SEAT_SOCKET", job.socket),
|
|
285
|
+
("SEAT_PUB", job.pub), ("SEAT_LOG", job.log))
|
|
286
|
+
return "".join(f"{k}={shlex.quote(v)}\n" for k, v in pairs)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def install(address: str, socket: str | None = None, *, env: Mapping[str, str] | None = None,
|
|
290
|
+
out: IO[str] | None = None, err: IO[str] | None = None) -> int:
|
|
291
|
+
env = os.environ if env is None else env
|
|
292
|
+
out = sys.stdout if out is None else out
|
|
293
|
+
err = sys.stderr if err is None else err
|
|
294
|
+
try:
|
|
295
|
+
return _install(address, socket, env, out, err)
|
|
296
|
+
except Refused as exc:
|
|
297
|
+
err.write(f"{PROG}: REFUSED - {exc}. Nothing was written.\n")
|
|
298
|
+
return EX_REFUSED
|
|
299
|
+
except Failed as exc:
|
|
300
|
+
err.write(f"{PROG}: FAILED - {exc}\n")
|
|
301
|
+
return EX_FAILED
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _install(address: str, socket: str | None, env: Mapping[str, str],
|
|
305
|
+
out: IO[str], err: IO[str]) -> int:
|
|
306
|
+
job = plan(address, socket, env)
|
|
307
|
+
with open(TEMPLATE, "rb") as f:
|
|
308
|
+
rendered = render(f.read(), job)
|
|
309
|
+
existing = _read(job.plist)
|
|
310
|
+
is_loaded = loaded(job.target, env)
|
|
311
|
+
if is_loaded and existing == rendered:
|
|
312
|
+
if not _wait(lambda: answers(job, env), ANSWER_SECONDS):
|
|
313
|
+
raise Failed(f"{job.label} is loaded but its agent does not answer on {job.socket} "
|
|
314
|
+
f"with seat.pub's key; the job was left as found (this run did not "
|
|
315
|
+
f"load it). {_log_tail(job)}")
|
|
316
|
+
err.write(f"{PROG}: {job.label} already installed; the agent answers on {job.socket}\n")
|
|
317
|
+
out.write(report(job))
|
|
318
|
+
return EX_OK
|
|
319
|
+
if is_loaded:
|
|
320
|
+
why = "with a different plist" if existing is not None else f"with no plist at {job.plist}"
|
|
321
|
+
raise Refused(f"a job already holds {job.label} ({why}): run "
|
|
322
|
+
f"`{PROG} --uninstall {address}` first")
|
|
323
|
+
if existing is not None and existing != rendered:
|
|
324
|
+
raise Refused(f"a different plist is at {job.plist}: run "
|
|
325
|
+
f"`{PROG} --uninstall {address}` first; a job's plist is never overwritten")
|
|
326
|
+
|
|
327
|
+
wrote = existing is None
|
|
328
|
+
# An OSError here is a FAILED line, never a traceback. _write leaves no temp
|
|
329
|
+
# file and no plist behind, and nothing has been loaded yet. A plist that was
|
|
330
|
+
# there before this run is never touched, so the line says it is kept.
|
|
331
|
+
path = job.log_dir
|
|
332
|
+
try:
|
|
333
|
+
os.makedirs(job.log_dir, exist_ok=True)
|
|
334
|
+
path = job.plist
|
|
335
|
+
if wrote:
|
|
336
|
+
_write(job.plist, rendered)
|
|
337
|
+
except OSError as exc:
|
|
338
|
+
kept = ("no plist was left" if wrote
|
|
339
|
+
else f"the plist at {job.plist} was there before this run and is kept")
|
|
340
|
+
raise Failed(f"could not write {path} ({exc.strerror or exc}); nothing was "
|
|
341
|
+
f"loaded and {kept}") from exc
|
|
342
|
+
done = launchctl(env, "bootstrap", job.domain, job.plist)
|
|
343
|
+
if done.returncode != 0:
|
|
344
|
+
put_back = _remove_ours(job, wrote)
|
|
345
|
+
raise Failed(f"launchctl bootstrap {job.domain} {job.plist} failed ({_words(done)}); "
|
|
346
|
+
f"{put_back}")
|
|
347
|
+
if not _wait(lambda: answers(job, env), ANSWER_SECONDS):
|
|
348
|
+
off = launchctl(env, "bootout", job.target)
|
|
349
|
+
booted = ("booted it out" if off.returncode == 0
|
|
350
|
+
else f"its bootout failed too ({_words(off)})")
|
|
351
|
+
put_back = _remove_ours(job, wrote)
|
|
352
|
+
raise Failed(f"loaded {job.label}, but its agent did not answer on {job.socket} with "
|
|
353
|
+
f"seat.pub's key within {ANSWER_SECONDS:g} s; {booted}; {put_back}. "
|
|
354
|
+
f"{_log_tail(job)}")
|
|
355
|
+
err.write(f"{PROG}: installed and loaded {job.label}; the agent answers on {job.socket}\n")
|
|
356
|
+
out.write(report(job))
|
|
357
|
+
return EX_OK
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _remove_ours(job: Job, wrote: bool) -> str:
|
|
361
|
+
if not wrote:
|
|
362
|
+
return f"the plist at {job.plist} was there before this run and is kept"
|
|
363
|
+
try:
|
|
364
|
+
os.unlink(job.plist)
|
|
365
|
+
except FileNotFoundError:
|
|
366
|
+
pass
|
|
367
|
+
except OSError as exc:
|
|
368
|
+
return f"the plist this run wrote could NOT be removed ({job.plist}: {exc.strerror})"
|
|
369
|
+
return f"the plist this run wrote is removed ({job.plist})"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def uninstall(address: str, *, env: Mapping[str, str] | None = None,
|
|
373
|
+
err: IO[str] | None = None) -> int:
|
|
374
|
+
env = os.environ if env is None else env
|
|
375
|
+
err = sys.stderr if err is None else err
|
|
376
|
+
try:
|
|
377
|
+
return _uninstall(address, env, err)
|
|
378
|
+
except Refused as exc:
|
|
379
|
+
err.write(f"{PROG}: REFUSED - {exc}. Nothing was changed.\n")
|
|
380
|
+
return EX_REFUSED
|
|
381
|
+
except Failed as exc:
|
|
382
|
+
err.write(f"{PROG}: FAILED - {exc}\n")
|
|
383
|
+
return EX_FAILED
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _uninstall(address: str, env: Mapping[str, str], err: IO[str]) -> int:
|
|
387
|
+
_check_platform()
|
|
388
|
+
_check_address(address)
|
|
389
|
+
home = _home(env)
|
|
390
|
+
label = label_for(address)
|
|
391
|
+
plist = os.path.join(home, "Library", "LaunchAgents", f"{label}.plist")
|
|
392
|
+
target = f"gui/{os.getuid()}/{label}"
|
|
393
|
+
existing = _read(plist)
|
|
394
|
+
if existing is not None:
|
|
395
|
+
try:
|
|
396
|
+
theirs = plistlib.loads(existing).get("Label")
|
|
397
|
+
except Exception:
|
|
398
|
+
theirs = None
|
|
399
|
+
if theirs != label:
|
|
400
|
+
raise Refused(f"the plist at {plist} has Label {theirs!r}, not {label!r}: "
|
|
401
|
+
"not this tool's to remove")
|
|
402
|
+
said = []
|
|
403
|
+
if loaded(target, env):
|
|
404
|
+
off = launchctl(env, "bootout", target)
|
|
405
|
+
if off.returncode != 0:
|
|
406
|
+
raise Failed(f"launchctl bootout {target} failed ({_words(off)}); "
|
|
407
|
+
f"the plist is kept" + (f" at {plist}" if existing is not None else ""))
|
|
408
|
+
if not _wait(lambda: not loaded(target, env), GONE_SECONDS):
|
|
409
|
+
raise Failed(f"{label} is still loaded {GONE_SECONDS:g} s after its bootout; "
|
|
410
|
+
"the plist is kept" + (f" at {plist}" if existing is not None else ""))
|
|
411
|
+
said.append(f"booted out {label}")
|
|
412
|
+
if existing is not None:
|
|
413
|
+
try:
|
|
414
|
+
os.unlink(plist)
|
|
415
|
+
except FileNotFoundError:
|
|
416
|
+
pass
|
|
417
|
+
except OSError as exc:
|
|
418
|
+
done = f"{'; '.join(said)}; but " if said else ""
|
|
419
|
+
raise Failed(f"{done}could not remove {plist} ({exc.strerror or exc}); "
|
|
420
|
+
"the plist is kept") from exc
|
|
421
|
+
said.append(f"removed {plist}")
|
|
422
|
+
if not said:
|
|
423
|
+
err.write(f"{PROG}: nothing installed for {address}\n")
|
|
424
|
+
else:
|
|
425
|
+
err.write(f"{PROG}: {'; '.join(said)}\n")
|
|
426
|
+
return EX_OK
|