twin-update 0.2.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.
- twin_update/__init__.py +3 -0
- twin_update/__main__.py +3 -0
- twin_update/cli.py +255 -0
- twin_update/config.py +162 -0
- twin_update/engine.py +759 -0
- twin_update/orchestrator.py +338 -0
- twin_update/peer.py +121 -0
- twin_update/system.py +248 -0
- twin_update-0.2.0.dist-info/METADATA +202 -0
- twin_update-0.2.0.dist-info/RECORD +14 -0
- twin_update-0.2.0.dist-info/WHEEL +5 -0
- twin_update-0.2.0.dist-info/entry_points.txt +2 -0
- twin_update-0.2.0.dist-info/licenses/LICENSE +202 -0
- twin_update-0.2.0.dist-info/top_level.txt +1 -0
twin_update/__init__.py
ADDED
twin_update/__main__.py
ADDED
twin_update/cli.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Command line: twin-update status|run|rollback|holds|unhold|doctor|local."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from twin_update import __version__, orchestrator
|
|
14
|
+
from twin_update.config import ConfigError, load
|
|
15
|
+
from twin_update.engine import Engine
|
|
16
|
+
from twin_update.peer import LocalPeer, PeerError, SshPeer, serve
|
|
17
|
+
from twin_update.system import System
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConsoleUI:
|
|
21
|
+
def say(self, msg: str) -> None:
|
|
22
|
+
try:
|
|
23
|
+
print(msg, flush=True)
|
|
24
|
+
except OSError: # terminal went away (e.g. the app hosting it was closed)
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def getpass(self, prompt: str) -> str:
|
|
28
|
+
return getpass.getpass(prompt)
|
|
29
|
+
|
|
30
|
+
def confirm(self, prompt: str) -> bool:
|
|
31
|
+
if not sys.stdin.isatty():
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
return input(prompt + " [y/N] ").strip().lower() in ("y", "yes")
|
|
35
|
+
except (EOFError, OSError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detach_if_needed(args) -> None:
|
|
40
|
+
"""Re-exec in its own systemd user scope so closing the app that hosts our
|
|
41
|
+
terminal (e.g. Grok Bot) does not kill the run. The scope ends with us."""
|
|
42
|
+
if args.no_detach or os.environ.get("TWIN_UPDATE_SCOPE") == "1":
|
|
43
|
+
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
44
|
+
return
|
|
45
|
+
if not shutil.which("systemd-run"):
|
|
46
|
+
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
47
|
+
return
|
|
48
|
+
argv = ["systemd-run", "--user", "--scope", "--quiet", "--collect", "--",
|
|
49
|
+
"env", "TWIN_UPDATE_SCOPE=1", sys.executable, *sys.argv]
|
|
50
|
+
os.execvp(argv[0], argv)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build(args, ui, system=None, peer_factory=SshPeer):
|
|
54
|
+
system = system or System()
|
|
55
|
+
cfg = load(args.config)
|
|
56
|
+
me = cfg.self_label(system.whoami(), system.machine_id())
|
|
57
|
+
peer = cfg.peer_label(me)
|
|
58
|
+
peers = {me: LocalPeer(Engine(cfg, system, me, log=lambda m: None))}
|
|
59
|
+
unreachable = {}
|
|
60
|
+
if not getattr(args, "local_only", False):
|
|
61
|
+
mach = cfg.machines[peer]
|
|
62
|
+
sp = None
|
|
63
|
+
try:
|
|
64
|
+
sp = peer_factory(peer, mach.ssh_target, mach.remote_command)
|
|
65
|
+
sp.call("identity")
|
|
66
|
+
peers[peer] = sp
|
|
67
|
+
except (PeerError, OSError, ValueError) as exc:
|
|
68
|
+
unreachable[peer] = str(exc)
|
|
69
|
+
if sp is not None:
|
|
70
|
+
try:
|
|
71
|
+
sp.proc.terminate()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
else:
|
|
75
|
+
unreachable[peer] = "skipped (--local-only)"
|
|
76
|
+
return cfg, orchestrator.Session(cfg, peers, [me, peer], ui), unreachable, me, peer
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print(ui, data, as_json: bool) -> None:
|
|
80
|
+
if as_json:
|
|
81
|
+
ui.say(json.dumps(data, indent=2, sort_keys=True))
|
|
82
|
+
return
|
|
83
|
+
kind = data.get("kind")
|
|
84
|
+
if kind == "status":
|
|
85
|
+
ui.say(f"{'machine':8} {'app':9} {'installed':22} {'candidate':22} run rollback hold")
|
|
86
|
+
for l, st in data["machines"].items():
|
|
87
|
+
for a, r in st["apps"].items():
|
|
88
|
+
ui.say(f"{l:8} {a:9} {str(r['installed']):22} {str(r['candidate']):22} "
|
|
89
|
+
f"{'yes' if r['running'] else 'no ':4} {','.join(r['rollback']) or '-':13} "
|
|
90
|
+
f"{r['hold'] or '-'}")
|
|
91
|
+
if st.get("other_holds"):
|
|
92
|
+
ui.say(f"{l:8} other held packages (not touched): {', '.join(st['other_holds'])}")
|
|
93
|
+
for l, why in data.get("unreachable", {}).items():
|
|
94
|
+
ui.say(f"{l:8} unreachable: {why}")
|
|
95
|
+
return
|
|
96
|
+
if kind == "doctor":
|
|
97
|
+
for l, checks in data.get("machines", {}).items():
|
|
98
|
+
if "error" in checks:
|
|
99
|
+
ui.say(f"{l:8} error: {checks['error']}")
|
|
100
|
+
continue
|
|
101
|
+
bad = []
|
|
102
|
+
for k in sorted(checks):
|
|
103
|
+
v = checks[k]
|
|
104
|
+
if k == "locks":
|
|
105
|
+
for path, state in v.items():
|
|
106
|
+
ui.say(f"{l:8} lock {path}: {state}")
|
|
107
|
+
continue
|
|
108
|
+
if k == "notify_tool":
|
|
109
|
+
ui.say(f"{l:8} notify: {v or 'none (notifications skipped)'}")
|
|
110
|
+
continue
|
|
111
|
+
ok = bool(v)
|
|
112
|
+
ui.say(f"{l:8} {k:18} {'ok' if ok else 'NO'}")
|
|
113
|
+
if not ok:
|
|
114
|
+
bad.append(k)
|
|
115
|
+
ui.say(f"{l:8} {'all checks ok' if not bad else 'problems: ' + ', '.join(bad)}")
|
|
116
|
+
for l, why in data.get("unreachable", {}).items():
|
|
117
|
+
ui.say(f"{l:8} unreachable: {why}")
|
|
118
|
+
return
|
|
119
|
+
if kind == "holds":
|
|
120
|
+
for l, h in data.get("machines", {}).items():
|
|
121
|
+
managed = h.get("managed", {})
|
|
122
|
+
ui.say(f"{l:8} app holds: " + (", ".join(f"{a} ({o})" for a, o in sorted(managed.items()))
|
|
123
|
+
or "none"))
|
|
124
|
+
if h.get("other"):
|
|
125
|
+
ui.say(f"{l:8} other held packages (not touched): {', '.join(h['other'])}")
|
|
126
|
+
return
|
|
127
|
+
if kind == "unhold":
|
|
128
|
+
for l, r in data.get("machines", {}).items():
|
|
129
|
+
ui.say(f"{l:8} {r.get('action', r.get('error', '?'))}")
|
|
130
|
+
return
|
|
131
|
+
printed = False
|
|
132
|
+
for line in data.get("summary", []):
|
|
133
|
+
ui.say(line)
|
|
134
|
+
printed = True
|
|
135
|
+
for b in data.get("blockers", []):
|
|
136
|
+
ui.say(f"blocker: {b}")
|
|
137
|
+
if data.get("result"):
|
|
138
|
+
ui.say(f"result: {data['result']}")
|
|
139
|
+
if data.get("error") and data.get("result") != "aborted":
|
|
140
|
+
ui.say(f"error: {data['error']}")
|
|
141
|
+
if not (printed or data.get("blockers") or data.get("result") or data.get("error")):
|
|
142
|
+
# Never stay silent: fall back to the JSON report.
|
|
143
|
+
ui.say(json.dumps(data, indent=2, sort_keys=True))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv=None, ui=None, system=None, peer_factory=SshPeer) -> int:
|
|
147
|
+
ui = ui or ConsoleUI()
|
|
148
|
+
ap = argparse.ArgumentParser(prog="twin-update", description=__doc__)
|
|
149
|
+
ap.add_argument("--version", action="version", version=f"twin-update {__version__}")
|
|
150
|
+
ap.add_argument("--config", default=None, help="config path (default ~/.config/twin-update/config.toml)")
|
|
151
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
152
|
+
|
|
153
|
+
def common(p, local=True):
|
|
154
|
+
p.add_argument("--json", action="store_true", help="print the full JSON report")
|
|
155
|
+
if local:
|
|
156
|
+
p.add_argument("--local-only", action="store_true",
|
|
157
|
+
help="this machine only (no ssh); run/rollback allow it with --dry-run only")
|
|
158
|
+
|
|
159
|
+
p = sub.add_parser("status"); common(p)
|
|
160
|
+
p = sub.add_parser("run"); common(p)
|
|
161
|
+
p.add_argument("--apps", default=None, help="comma list (default: all enabled)")
|
|
162
|
+
p.add_argument("--dry-run", action="store_true")
|
|
163
|
+
p.add_argument("--no-smoke", action="store_true", help="skip the launch smoke test")
|
|
164
|
+
p.add_argument("--no-detach", action="store_true")
|
|
165
|
+
p = sub.add_parser("rollback"); common(p)
|
|
166
|
+
p.add_argument("app")
|
|
167
|
+
p.add_argument("--machine", default="both")
|
|
168
|
+
p.add_argument("--dry-run", action="store_true")
|
|
169
|
+
p.add_argument("--no-hold", action="store_true")
|
|
170
|
+
p.add_argument("--no-smoke", action="store_true")
|
|
171
|
+
p.add_argument("--no-detach", action="store_true")
|
|
172
|
+
p = sub.add_parser("holds"); common(p)
|
|
173
|
+
p = sub.add_parser("unhold"); common(p)
|
|
174
|
+
p.add_argument("app")
|
|
175
|
+
p.add_argument("--machine", default="both")
|
|
176
|
+
p.add_argument("--dry-run", action="store_true")
|
|
177
|
+
p = sub.add_parser("doctor"); common(p)
|
|
178
|
+
p = sub.add_parser("local", help="per-machine engine (used by the peer over ssh)")
|
|
179
|
+
p.add_argument("--serve", action="store_true")
|
|
180
|
+
p.add_argument("--as", dest="as_label", default=None)
|
|
181
|
+
|
|
182
|
+
args = ap.parse_args(argv)
|
|
183
|
+
system = system or System()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
if args.cmd == "local":
|
|
187
|
+
cfg = load(args.config)
|
|
188
|
+
me = cfg.self_label(system.whoami(), system.machine_id())
|
|
189
|
+
if args.as_label and args.as_label != me:
|
|
190
|
+
ui.say(json.dumps({"id": None, "ok": False,
|
|
191
|
+
"error": f"identity mismatch: this machine is {me}"}))
|
|
192
|
+
return 2
|
|
193
|
+
if not args.serve:
|
|
194
|
+
ui.say("use --serve")
|
|
195
|
+
return 2
|
|
196
|
+
return serve(Engine(cfg, system, me), sys.stdin.buffer, sys.stdout.buffer)
|
|
197
|
+
|
|
198
|
+
if args.cmd in ("run", "rollback") and not args.dry_run:
|
|
199
|
+
if getattr(args, "local_only", False):
|
|
200
|
+
ui.say("--local-only is allowed only with --dry-run")
|
|
201
|
+
return 2
|
|
202
|
+
_detach_if_needed(args)
|
|
203
|
+
|
|
204
|
+
cfg, sess, unreachable, me, peer = _build(args, ui, system, peer_factory)
|
|
205
|
+
try:
|
|
206
|
+
if args.cmd == "status":
|
|
207
|
+
data = orchestrator.status(sess)
|
|
208
|
+
data["unreachable"] = unreachable
|
|
209
|
+
elif args.cmd == "run":
|
|
210
|
+
apps = args.apps.split(",") if args.apps else list(cfg.enabled)
|
|
211
|
+
for a in apps:
|
|
212
|
+
if a not in cfg.enabled:
|
|
213
|
+
ui.say(f"app not enabled in config: {a}")
|
|
214
|
+
return 2
|
|
215
|
+
data = orchestrator.run(sess, apps, args.dry_run, smoke=not args.no_smoke,
|
|
216
|
+
unreachable=unreachable)
|
|
217
|
+
elif args.cmd == "rollback":
|
|
218
|
+
labels = _labels(args.machine, me, peer, sess)
|
|
219
|
+
data = orchestrator.rollback(sess, args.app, labels, args.dry_run,
|
|
220
|
+
smoke=not args.no_smoke, hold=not args.no_hold)
|
|
221
|
+
elif args.cmd == "holds":
|
|
222
|
+
data = orchestrator.holds(sess)
|
|
223
|
+
elif args.cmd == "unhold":
|
|
224
|
+
data = orchestrator.unhold(sess, args.app, _labels(args.machine, me, peer, sess),
|
|
225
|
+
args.dry_run)
|
|
226
|
+
else:
|
|
227
|
+
data = orchestrator.doctor(sess, unreachable)
|
|
228
|
+
finally:
|
|
229
|
+
sess.close()
|
|
230
|
+
except ConfigError as exc:
|
|
231
|
+
ui.say(f"config error: {exc}")
|
|
232
|
+
return 2
|
|
233
|
+
except PeerError as exc:
|
|
234
|
+
ui.say(f"error: {exc}")
|
|
235
|
+
return 1
|
|
236
|
+
_print(ui, data, args.json)
|
|
237
|
+
res = data.get("result")
|
|
238
|
+
return 0 if res in (None, "ok", "dry-run") else (3 if res == "aborted" else 1)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _labels(choice: str, me: str, peer: str, sess) -> list[str]:
|
|
242
|
+
if choice == "both":
|
|
243
|
+
labels = [me, peer]
|
|
244
|
+
elif choice in (me, peer):
|
|
245
|
+
labels = [choice]
|
|
246
|
+
else:
|
|
247
|
+
raise ConfigError(f"--machine must be both, {me} or {peer}")
|
|
248
|
+
missing = [l for l in labels if l not in sess.peers]
|
|
249
|
+
if missing:
|
|
250
|
+
raise PeerError(f"unreachable: {', '.join(missing)}")
|
|
251
|
+
return labels
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
raise SystemExit(main())
|
twin_update/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Configuration (~/.config/twin-update/config.toml). No personal defaults here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
DEFAULT_PATH = "~/.config/twin-update/config.toml"
|
|
10
|
+
|
|
11
|
+
# Generic package facts only. Paths are where the vendor .debs install.
|
|
12
|
+
BUILTIN_APPS = {
|
|
13
|
+
"cursor": {
|
|
14
|
+
"package": "cursor",
|
|
15
|
+
"roots": ["/usr/share/cursor"],
|
|
16
|
+
"launch": ["/usr/share/cursor/cursor"],
|
|
17
|
+
"display": "Cursor",
|
|
18
|
+
},
|
|
19
|
+
"chatgpt": {
|
|
20
|
+
"package": "chatgpt",
|
|
21
|
+
"roots": ["/usr/lib/chatgpt"],
|
|
22
|
+
"launch": ["/usr/bin/chatgpt"],
|
|
23
|
+
"display": "ChatGPT",
|
|
24
|
+
},
|
|
25
|
+
"grok-bot": {
|
|
26
|
+
"package": "grok-bot",
|
|
27
|
+
"roots": ["/opt/Grok Bot"],
|
|
28
|
+
"launch": ["/usr/bin/grok-bot"],
|
|
29
|
+
"display": "Grok Bot",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Machine:
|
|
40
|
+
label: str
|
|
41
|
+
user: str
|
|
42
|
+
ssh_target: str = ""
|
|
43
|
+
remote_command: str = "~/.local/bin/twin-update"
|
|
44
|
+
machine_id_sha256: str = ""
|
|
45
|
+
lock_paths: list[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class App:
|
|
50
|
+
name: str
|
|
51
|
+
package: str
|
|
52
|
+
roots: list[str]
|
|
53
|
+
launch: list[str]
|
|
54
|
+
display: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Config:
|
|
59
|
+
machines: dict[str, Machine]
|
|
60
|
+
apps: dict[str, App]
|
|
61
|
+
enabled: list[str]
|
|
62
|
+
keep_holds: list[str]
|
|
63
|
+
fallback_rollback: list[str]
|
|
64
|
+
close_timeout_s: float = 90.0
|
|
65
|
+
smoke_s: float = 20.0
|
|
66
|
+
apt_lock_timeout_s: int = 300
|
|
67
|
+
lock_wait_s: float = 600.0
|
|
68
|
+
store: str = "~/.local/share/twin-update/rollback"
|
|
69
|
+
state_dir: str = "~/.local/state/twin-update"
|
|
70
|
+
search_dirs: list[str] = field(default_factory=lambda: ["~/Downloads"])
|
|
71
|
+
grace_days: float = 7.0
|
|
72
|
+
grace_launches: int = 2
|
|
73
|
+
|
|
74
|
+
def self_label(self, whoami: str, machine_id_sha256: str = "") -> str:
|
|
75
|
+
"""Find this machine's label: by user, then by machine-id when the
|
|
76
|
+
same user name exists on both machines."""
|
|
77
|
+
hits = [m for m in self.machines.values() if m.user == whoami]
|
|
78
|
+
if len(hits) > 1:
|
|
79
|
+
hits = [m for m in hits if m.machine_id_sha256 and m.machine_id_sha256 == machine_id_sha256]
|
|
80
|
+
if len(hits) != 1:
|
|
81
|
+
raise ConfigError(
|
|
82
|
+
"both machines use the same user name: set machine_id_sha256 on each "
|
|
83
|
+
"[machines.*] table (value: tr -d '\\n' < /etc/machine-id | sha256sum)")
|
|
84
|
+
if len(hits) != 1:
|
|
85
|
+
raise ConfigError(
|
|
86
|
+
f"whoami does not match exactly one [machines.*].user (matches: {len(hits)})")
|
|
87
|
+
return hits[0].label
|
|
88
|
+
|
|
89
|
+
def peer_label(self, me: str) -> str:
|
|
90
|
+
others = [k for k in self.machines if k != me]
|
|
91
|
+
if len(others) != 1:
|
|
92
|
+
raise ConfigError("config must list exactly two machines")
|
|
93
|
+
return others[0]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _expand(p: str, home: str) -> str:
|
|
97
|
+
if p == "~" or p.startswith("~/"):
|
|
98
|
+
return home + p[1:]
|
|
99
|
+
return p
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load(path: str | None = None, home: str | None = None, text: str | None = None) -> Config:
|
|
103
|
+
home = home or os.path.expanduser("~")
|
|
104
|
+
if text is None:
|
|
105
|
+
real = _expand(path or DEFAULT_PATH, home)
|
|
106
|
+
try:
|
|
107
|
+
with open(real, "rb") as fh:
|
|
108
|
+
data = tomllib.load(fh)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
raise ConfigError(f"config not found: {path or DEFAULT_PATH}") from None
|
|
111
|
+
else:
|
|
112
|
+
data = tomllib.loads(text)
|
|
113
|
+
|
|
114
|
+
msec = data.get("machines") or {}
|
|
115
|
+
if len(msec) != 2:
|
|
116
|
+
raise ConfigError("config must define exactly two [machines.<label>] tables")
|
|
117
|
+
machines = {}
|
|
118
|
+
for label, m in msec.items():
|
|
119
|
+
if not isinstance(m, dict) or not m.get("user"):
|
|
120
|
+
raise ConfigError(f"[machines.{label}] needs user")
|
|
121
|
+
machines[label] = Machine(
|
|
122
|
+
label=label, user=m["user"], ssh_target=m.get("ssh_target", ""),
|
|
123
|
+
remote_command=m.get("remote_command", "~/.local/bin/twin-update"),
|
|
124
|
+
machine_id_sha256=m.get("machine_id_sha256", ""),
|
|
125
|
+
lock_paths=[_expand(p, home) for p in m.get("lock_paths", [])],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
asec = data.get("apps") or {}
|
|
129
|
+
apps: dict[str, App] = {}
|
|
130
|
+
for name, base in BUILTIN_APPS.items():
|
|
131
|
+
over = asec.get(name, {}) if isinstance(asec.get(name), dict) else {}
|
|
132
|
+
merged = {**base, **over}
|
|
133
|
+
apps[name] = App(name, merged["package"], list(merged["roots"]),
|
|
134
|
+
list(merged["launch"]), merged["display"])
|
|
135
|
+
for name, over in asec.items():
|
|
136
|
+
if isinstance(over, dict) and name not in apps:
|
|
137
|
+
for k in ("package", "roots", "launch"):
|
|
138
|
+
if k not in over:
|
|
139
|
+
raise ConfigError(f"[apps.{name}] needs {k}")
|
|
140
|
+
apps[name] = App(name, over["package"], list(over["roots"]),
|
|
141
|
+
list(over["launch"]), over.get("display", name))
|
|
142
|
+
enabled = list(asec.get("enabled", list(BUILTIN_APPS)))
|
|
143
|
+
for n in enabled:
|
|
144
|
+
if n not in apps:
|
|
145
|
+
raise ConfigError(f"unknown app in enabled: {n}")
|
|
146
|
+
|
|
147
|
+
t = data.get("timeouts") or {}
|
|
148
|
+
r = data.get("rollback") or {}
|
|
149
|
+
return Config(
|
|
150
|
+
machines=machines, apps=apps, enabled=enabled,
|
|
151
|
+
keep_holds=list(asec.get("keep_holds", [])),
|
|
152
|
+
fallback_rollback=list(r.get("fallback", [])),
|
|
153
|
+
close_timeout_s=float(t.get("close_s", 90)),
|
|
154
|
+
smoke_s=float(t.get("smoke_s", 20)),
|
|
155
|
+
apt_lock_timeout_s=int(t.get("apt_lock_s", 300)),
|
|
156
|
+
lock_wait_s=float(t.get("lock_wait_s", 600)),
|
|
157
|
+
store=_expand(r.get("store", "~/.local/share/twin-update/rollback"), home),
|
|
158
|
+
state_dir=_expand(data.get("state_dir", "~/.local/state/twin-update"), home),
|
|
159
|
+
search_dirs=[_expand(p, home) for p in r.get("search_dirs", ["~/Downloads"])],
|
|
160
|
+
grace_days=float(r.get("grace_days", 7)),
|
|
161
|
+
grace_launches=int(r.get("grace_launches", 2)),
|
|
162
|
+
)
|