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.
@@ -0,0 +1,3 @@
1
+ """Twin Update: update the same desktop apps on two Linux machines in one run."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from twin_update.cli import main
2
+
3
+ raise SystemExit(main())
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
+ )