cookiesync-cli 0.1.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.
cookiesync/service.py ADDED
@@ -0,0 +1,214 @@
1
+ """Manage the two macOS LaunchAgents that drive cookiesync: a long-lived watch daemon and a periodic reconcile tick.
2
+
3
+ Mirrors reposync's service layer. Plist generation is a pure function so tests assert
4
+ the exact XML; the launchctl boundary is an injected :class:`Launcher` so tests never
5
+ bootstrap real agents. Three sharp edges are deliberate and must survive any cleanup:
6
+
7
+ * ``EnvironmentVariables.PATH`` prepends ``/opt/homebrew/bin`` — launchd strips the
8
+ Homebrew prefixes where ``reposync`` and the browsers live, so the daemon would fail
9
+ to resolve them otherwise.
10
+ * the program path is **not** symlink-resolved (it points at the stable installed
11
+ ``cookiesync`` entrypoint), so a ``uv`` or ``brew`` upgrade that relinks the binary
12
+ never strands the agent at a deleted versioned path.
13
+ * ``LimitLoadToSessionType`` is ``Aqua`` (the GUI session), required for the keychain
14
+ and Touch-ID access the consent layer needs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import plistlib
21
+ import shutil
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, NewType, Protocol
25
+
26
+ import anyio
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Sequence
30
+
31
+ Label = NewType("Label", str)
32
+
33
+ WATCH_LABEL = Label("com.github.yasyf.cookiesync.watch")
34
+ RECONCILE_LABEL = Label("com.github.yasyf.cookiesync.reconcile")
35
+
36
+ LAUNCH_AGENTS = Path("Library/LaunchAgents")
37
+
38
+ DAEMON_PATH = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
39
+
40
+ SESSION_TYPE = "Aqua"
41
+
42
+ RECONCILE_INTERVAL = 900
43
+
44
+ ALREADY_LOADED = "service already loaded"
45
+ NOT_LOADED = "Could not find specified service"
46
+
47
+
48
+ class ServiceError(Exception):
49
+ """A ``launchctl`` invocation failed for a reason other than an already-loaded or not-loaded agent."""
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class AgentSpec:
54
+ """One LaunchAgent: its label, the ``cookiesync`` subcommand it runs, and the launchd keys unique to it.
55
+
56
+ The watch daemon carries ``KeepAlive``; the reconcile tick carries ``StartInterval``.
57
+ Everything common — the PATH override, the Aqua session limit, ``RunAtLoad`` — lives
58
+ in :func:`render` so the two agents can never diverge on it.
59
+ """
60
+
61
+ label: Label
62
+ command: str
63
+ extra: tuple[tuple[str, object], ...]
64
+
65
+ def render(self) -> bytes:
66
+ return plistlib.dumps(
67
+ {
68
+ "Label": self.label,
69
+ "ProgramArguments": [program_path(), self.command],
70
+ "EnvironmentVariables": {"PATH": DAEMON_PATH},
71
+ "RunAtLoad": True,
72
+ "LimitLoadToSessionType": SESSION_TYPE,
73
+ "ProcessType": "Background",
74
+ "StandardOutPath": str(log_path(self.label)),
75
+ "StandardErrorPath": str(log_path(self.label)),
76
+ }
77
+ | dict(self.extra),
78
+ sort_keys=True,
79
+ )
80
+
81
+
82
+ RECONCILE_AGENT = AgentSpec(RECONCILE_LABEL, "reconcile", (("StartInterval", RECONCILE_INTERVAL),))
83
+ WATCH_AGENT = AgentSpec(WATCH_LABEL, "watch", (("KeepAlive", True),))
84
+ AGENTS: tuple[AgentSpec, ...] = (RECONCILE_AGENT, WATCH_AGENT)
85
+
86
+
87
+ class Launcher(Protocol):
88
+ """The ``launchctl`` boundary: bootstraps and boots out LaunchAgents.
89
+
90
+ Tests inject a fake so install/uninstall never touch the real launchd domain.
91
+ """
92
+
93
+ async def bootstrap(self, plist: Path) -> None:
94
+ """Load the agent described by ``plist`` into this user's GUI launchd domain."""
95
+ ...
96
+
97
+ async def bootout(self, label: Label) -> None:
98
+ """Remove the agent ``label`` from this user's GUI launchd domain."""
99
+ ...
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class LaunchctlLauncher:
104
+ """The production :class:`Launcher`: shells out to ``launchctl bootstrap``/``bootout``.
105
+
106
+ Both verbs target the caller's ``gui/<uid>`` domain. An "already loaded" bootstrap
107
+ and a "not loaded" bootout are tolerated so install and uninstall are idempotent;
108
+ any other non-zero exit raises :class:`ServiceError`.
109
+ """
110
+
111
+ async def bootstrap(self, plist: Path) -> None:
112
+ await run_launchctl("bootstrap", gui_domain(), str(plist), tolerate=ALREADY_LOADED)
113
+
114
+ async def bootout(self, label: Label) -> None:
115
+ await run_launchctl("bootout", f"{gui_domain()}/{label}", tolerate=NOT_LOADED)
116
+
117
+
118
+ def gui_domain() -> str:
119
+ return f"gui/{os.getuid()}"
120
+
121
+
122
+ def launch_agents_dir() -> Path:
123
+ return Path.home() / LAUNCH_AGENTS
124
+
125
+
126
+ def log_path(label: Label) -> Path:
127
+ return Path.home() / "Library" / "Logs" / f"{label}.log"
128
+
129
+
130
+ def plist_path(label: Label) -> Path:
131
+ """The on-disk ``~/Library/LaunchAgents/<label>.plist`` path for ``label``."""
132
+ return launch_agents_dir() / f"{label}.plist"
133
+
134
+
135
+ def program_path() -> str:
136
+ """The stable ``cookiesync`` entrypoint, deliberately NOT symlink-resolved.
137
+
138
+ Resolving the symlink would bake a versioned ``uv``/Homebrew path into the plist
139
+ that the next upgrade purges; pointing at the installed entrypoint keeps the agent
140
+ valid across upgrades.
141
+ """
142
+ return shutil.which("cookiesync") or os.environ["COOKIESYNC_BIN"]
143
+
144
+
145
+ async def write_plist(label: Label, program_args: Sequence[str]) -> Path:
146
+ """Render the agent for ``label`` running ``program_args`` and write it to its plist path.
147
+
148
+ Args:
149
+ label: The LaunchAgent label, which selects its launchd-key profile.
150
+ program_args: The ``cookiesync`` subcommand the agent runs, e.g. ``["watch"]``.
151
+
152
+ Returns:
153
+ The ``~/Library/LaunchAgents/<label>.plist`` path the plist was written to.
154
+ """
155
+ await anyio.Path(launch_agents_dir()).mkdir(parents=True, exist_ok=True)
156
+ await (path := anyio.Path(plist_path(label))).write_bytes(agent_for(label, program_args).render())
157
+ return Path(path)
158
+
159
+
160
+ def agent_for(label: Label, program_args: Sequence[str]) -> AgentSpec:
161
+ match next(agent for agent in AGENTS if agent.label == label):
162
+ case agent if [agent.command] == list(program_args):
163
+ return agent
164
+ case agent:
165
+ raise ServiceError(f"{label} runs {agent.command!r}, not {list(program_args)!r}")
166
+
167
+
168
+ async def run_launchctl(*args: str, tolerate: str) -> None:
169
+ result = await anyio.run_process(["launchctl", *args], check=False)
170
+ match result.returncode:
171
+ case 0:
172
+ return
173
+ case _ if tolerate in result.stderr.decode():
174
+ return
175
+ case code:
176
+ raise ServiceError(f"launchctl {args[0]}: exit {code}: {result.stderr.decode().strip()}")
177
+
178
+
179
+ async def install(launcher: Launcher, *, tick_only: bool = False) -> None:
180
+ """Write and bootstrap the reconcile tick, and unless ``tick_only`` the watch daemon.
181
+
182
+ Each agent is booted out before bootstrap so a re-install picks up plist changes,
183
+ tolerating the not-loaded case on a first install.
184
+
185
+ Args:
186
+ launcher: The launchctl boundary that loads the agents.
187
+ tick_only: Install only the periodic reconcile tick, skipping the watch daemon.
188
+ """
189
+ await bootstrap_agent(launcher, RECONCILE_AGENT)
190
+ if tick_only:
191
+ return
192
+ await bootstrap_agent(launcher, WATCH_AGENT)
193
+
194
+
195
+ async def uninstall(launcher: Launcher) -> None:
196
+ """Boot out both LaunchAgents and remove their plist files; a missing file is not an error.
197
+
198
+ Args:
199
+ launcher: The launchctl boundary that boots out the agents.
200
+ """
201
+ async with anyio.create_task_group() as tg:
202
+ for agent in AGENTS:
203
+ tg.start_soon(remove_agent, launcher, agent.label)
204
+
205
+
206
+ async def bootstrap_agent(launcher: Launcher, agent: AgentSpec) -> None:
207
+ path = await write_plist(agent.label, [agent.command])
208
+ await launcher.bootout(agent.label)
209
+ await launcher.bootstrap(path)
210
+
211
+
212
+ async def remove_agent(launcher: Launcher, label: Label) -> None:
213
+ await launcher.bootout(label)
214
+ await anyio.Path(plist_path(label)).unlink(missing_ok=True)
cookiesync/state.py ADDED
@@ -0,0 +1,173 @@
1
+ """Load and persist cookiesync's ``state.json``: the self target, tracked browser endpoints, and cadence settings.
2
+
3
+ Mirrors reposync's on-disk model — Go-style duration strings (``"15m"``, ``"3s"``), an
4
+ atomic temp-file-plus-rename save, and a read-modify-write :func:`update` serialized across
5
+ processes by a filelock on :func:`cookiesync.paths.lock_path`. Hosts are *not* stored here;
6
+ they are read live from reposync elsewhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from collections.abc import Awaitable, Callable
14
+ from contextlib import asynccontextmanager
15
+ from dataclasses import asdict, dataclass, field
16
+ from datetime import timedelta
17
+ from inspect import isawaitable
18
+ from tempfile import mkstemp
19
+ from typing import TYPE_CHECKING, NewType
20
+
21
+ import anyio
22
+ from filelock import FileLock
23
+
24
+ from cookiesync.paths import config_dir, lock_path, state_path
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import AsyncIterator
28
+
29
+ SshTarget = NewType("SshTarget", str)
30
+ BrowserId = NewType("BrowserId", str)
31
+
32
+ DURATION_UNITS: tuple[tuple[str, int], ...] = (("h", 3600), ("m", 60), ("s", 1))
33
+ DURATION_SIZES: dict[str, int] = dict(DURATION_UNITS)
34
+
35
+
36
+ def parse_duration(text: str) -> timedelta:
37
+ """Parse a Go-style duration string such as ``"15m"`` or ``"90s"`` into a :class:`~datetime.timedelta`."""
38
+ return timedelta(seconds=int(text[:-1]) * DURATION_SIZES[text[-1]])
39
+
40
+
41
+ def format_duration(delta: timedelta) -> str:
42
+ """Render a :class:`~datetime.timedelta` as the most compact Go-style string, e.g. ``"15m"`` or ``"90s"``."""
43
+ return next(
44
+ f"{seconds // size}{unit}"
45
+ for unit, size in DURATION_UNITS
46
+ if (seconds := round(delta.total_seconds())) % size == 0
47
+ )
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Settings:
52
+ """Cadence knobs read by the sync, watch, and reconcile loops; serialized as Go-style durations."""
53
+
54
+ interval: timedelta = timedelta(minutes=15)
55
+ idle_threshold: timedelta = timedelta(minutes=5)
56
+ watch_debounce: timedelta = timedelta(seconds=3)
57
+ op_timeout: timedelta = timedelta(minutes=2)
58
+ auth_ttl: timedelta = timedelta(minutes=5)
59
+
60
+ def to_json(self) -> dict[str, str]:
61
+ return {key: format_duration(value) for key, value in asdict(self).items()}
62
+
63
+ @classmethod
64
+ def from_json(cls, raw: dict[str, str]) -> Settings:
65
+ return cls(**{key: parse_duration(value) for key, value in raw.items()})
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class BrowserEndpoint:
70
+ """One tracked browser profile on a host, keyed by its :attr:`id`.
71
+
72
+ Example:
73
+ >>> BrowserEndpoint(SshTarget("me@laptop"), BrowserId("arc"), "Default").id
74
+ 'me@laptop:arc:Default'
75
+ """
76
+
77
+ host: SshTarget
78
+ browser: BrowserId
79
+ profile: str
80
+
81
+ @property
82
+ def id(self) -> str:
83
+ """The endpoint's stable identity, ``host:browser:profile``."""
84
+ return f"{self.host}:{self.browser}:{self.profile}"
85
+
86
+ def to_json(self) -> dict[str, str]:
87
+ return {"host": self.host, "browser": self.browser, "profile": self.profile}
88
+
89
+ @classmethod
90
+ def from_json(cls, raw: dict[str, str]) -> BrowserEndpoint:
91
+ return cls(SshTarget(raw["host"]), BrowserId(raw["browser"]), raw["profile"])
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class State:
96
+ """The full on-disk cookiesync configuration for this host.
97
+
98
+ Example:
99
+ >>> await State(SshTarget("me@laptop")).save()
100
+ """
101
+
102
+ self_target: SshTarget
103
+ browsers: tuple[BrowserEndpoint, ...] = ()
104
+ settings: Settings = field(default_factory=Settings)
105
+
106
+ def to_json(self) -> dict[str, object]:
107
+ return {
108
+ "self_target": self.self_target,
109
+ "browsers": [endpoint.to_json() for endpoint in self.browsers],
110
+ "settings": self.settings.to_json(),
111
+ }
112
+
113
+ @classmethod
114
+ def from_json(cls, raw: dict[str, object]) -> State:
115
+ return cls(
116
+ SshTarget(raw["self_target"]),
117
+ tuple(BrowserEndpoint.from_json(endpoint) for endpoint in raw["browsers"]),
118
+ Settings.from_json(raw["settings"]),
119
+ )
120
+
121
+ async def save(self) -> State:
122
+ """Write this state to :func:`cookiesync.paths.state_path` atomically (temp file, then rename)."""
123
+ await write_json(self.to_json())
124
+ return self
125
+
126
+
127
+ async def read_json() -> dict[str, object] | None:
128
+ return json.loads(await path.read_text()) if await (path := anyio.Path(state_path())).exists() else None
129
+
130
+
131
+ async def write_json(payload: dict[str, object]) -> None:
132
+ await anyio.Path(config_dir()).mkdir(parents=True, exist_ok=True)
133
+ await (tmp := anyio.Path(await anyio.to_thread.run_sync(mktemp_path))).write_text(
134
+ json.dumps(payload, indent=2) + "\n"
135
+ )
136
+ os.replace(tmp, state_path())
137
+
138
+
139
+ def mktemp_path() -> str:
140
+ handle, name = mkstemp(dir=config_dir(), prefix="state-", suffix=".tmp")
141
+ os.close(handle)
142
+ return name
143
+
144
+
145
+ async def load() -> State:
146
+ """Read :func:`cookiesync.paths.state_path`, returning a default :class:`State` when the file is absent."""
147
+ match await read_json():
148
+ case None:
149
+ return State(default_self_target())
150
+ case raw:
151
+ return State.from_json(raw)
152
+
153
+
154
+ async def update(fn: Callable[[State], State | Awaitable[State]]) -> State:
155
+ """Read-modify-write the state under the reconcile flock, then save and return the result."""
156
+ async with hold_lock():
157
+ result = fn(await load())
158
+ return await (await result if isawaitable(result) else result).save()
159
+
160
+
161
+ @asynccontextmanager
162
+ async def hold_lock() -> AsyncIterator[None]:
163
+ await anyio.Path(config_dir()).mkdir(parents=True, exist_ok=True)
164
+ lock = FileLock(lock_path())
165
+ await anyio.to_thread.run_sync(lock.acquire)
166
+ try:
167
+ yield
168
+ finally:
169
+ lock.release()
170
+
171
+
172
+ def default_self_target() -> SshTarget:
173
+ return SshTarget(f"{os.environ['USER']}@{os.uname().nodename}")
@@ -0,0 +1,108 @@
1
+ """Async SSH transport: run a remote command over ssh and fan out across hosts.
2
+
3
+ Mirrors reposync's host transport — the same BatchMode/keepalive flag set and the
4
+ ``brew shellenv`` wrap, since a non-interactive ssh on macOS lacks brew (and thus
5
+ brew-installed cookiesync) on ``PATH``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ import anyio
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Awaitable, Callable, Sequence
18
+
19
+ from cookiesync.state import SshTarget
20
+
21
+ SSH_OPTS = (
22
+ "-o",
23
+ "BatchMode=yes",
24
+ "-o",
25
+ "ConnectTimeout=5",
26
+ "-o",
27
+ "ServerAliveInterval=5",
28
+ "-o",
29
+ "ServerAliveCountMax=3",
30
+ )
31
+
32
+ BREW_SHELLENV = "/opt/homebrew/bin/brew shellenv"
33
+
34
+ MAX_CONCURRENT_HOSTS = 8
35
+
36
+
37
+ class SshError(Exception):
38
+ """An ssh command exited non-zero, carrying the target and the remote stderr."""
39
+
40
+ def __init__(self, target: SshTarget, returncode: int, stderr: str) -> None:
41
+ self.target = target
42
+ self.returncode = returncode
43
+ self.stderr = stderr
44
+ super().__init__(f"ssh {target}: exit {returncode}: {stderr}")
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class HostResult[T]:
49
+ """The outcome of running a fan-out function against one host.
50
+
51
+ ``ok`` is ``True`` and ``value`` holds the return when the function succeeded;
52
+ ``ok`` is ``False`` and ``value`` holds the raised exception when it failed.
53
+ """
54
+
55
+ target: SshTarget
56
+ ok: bool
57
+ value: T | BaseException
58
+
59
+
60
+ def shell_quote(s: str) -> str:
61
+ """Quote ``s`` so it survives intact as one argument to a remote shell."""
62
+ return shlex.quote(s)
63
+
64
+
65
+ async def ssh(target: SshTarget, remote_cmd: str, *, stdin: bytes | None = None) -> str:
66
+ """Run ``remote_cmd`` on ``target`` over ssh and return its stdout.
67
+
68
+ The remote command is wrapped to source brew's shellenv first. ``stdin``, when
69
+ given, is piped to the remote command's standard input.
70
+
71
+ Raises:
72
+ SshError: the ssh process exited non-zero; carries the target and stderr.
73
+ """
74
+ result = await anyio.run_process(
75
+ ["ssh", *SSH_OPTS, target, f'eval "$({BREW_SHELLENV})" && {remote_cmd}'],
76
+ input=stdin,
77
+ check=False,
78
+ )
79
+ if result.returncode != 0:
80
+ raise SshError(target, result.returncode, result.stderr.decode().strip())
81
+ return result.stdout.decode()
82
+
83
+
84
+ async def each_host[T](
85
+ targets: Sequence[SshTarget],
86
+ fn: Callable[[SshTarget], Awaitable[T]],
87
+ *,
88
+ limit: int = MAX_CONCURRENT_HOSTS,
89
+ ) -> list[HostResult[T]]:
90
+ """Run ``fn`` against every target concurrently, bounded by ``limit``.
91
+
92
+ Each target's outcome is captured into a ``HostResult`` so one failing host
93
+ never aborts the batch. Results come back in input order.
94
+ """
95
+ semaphore = anyio.Semaphore(limit)
96
+ collected: dict[int, HostResult[T]] = {}
97
+
98
+ async def run(index: int, target: SshTarget) -> None:
99
+ async with semaphore:
100
+ try:
101
+ collected[index] = HostResult(target, True, await fn(target))
102
+ except Exception as exc: # noqa: BLE001 — per-host failures are collected, not swallowed
103
+ collected[index] = HostResult(target, False, exc)
104
+
105
+ async with anyio.create_task_group() as tg:
106
+ for index, target in enumerate(targets):
107
+ tg.start_soon(run, index, target)
108
+ return [collected[index] for index in range(len(targets))]
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: cookiesync-cli
3
+ Version: 0.1.0
4
+ Summary: Sync your browser cookies across machines.
5
+ Keywords:
6
+ Author: Yasyf Mohamedali
7
+ Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
8
+ License-Expression: PolyForm-Noncommercial-1.0.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: aiosqlite>=0.20
17
+ Requires-Dist: click>=8
18
+ Requires-Dist: cryptography>=43
19
+ Requires-Dist: filelock>=3.16
20
+ Requires-Dist: loguru>=0.7
21
+ Requires-Dist: watchfiles>=0.24
22
+ Requires-Dist: anyio>=4 ; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0 ; extra == 'dev'
24
+ Requires-Dist: ruff>=0.8 ; extra == 'dev'
25
+ Requires-Python: >=3.13
26
+ Project-URL: Homepage, https://github.com/yasyf/cookiesync
27
+ Project-URL: Repository, https://github.com/yasyf/cookiesync
28
+ Project-URL: Issues, https://github.com/yasyf/cookiesync/issues
29
+ Project-URL: Changelog, https://github.com/yasyf/cookiesync/blob/main/CHANGELOG.md
30
+ Provides-Extra: dev
31
+ Description-Content-Type: text/markdown
32
+
33
+ # cookiesync
34
+
35
+ ![cookiesync banner](https://github.com/yasyf/cookiesync/raw/main/docs/assets/readme-banner.webp)
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/cookiesync-cli.svg)](https://pypi.org/project/cookiesync-cli/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/cookiesync-cli.svg)](https://pypi.org/project/cookiesync-cli/)
39
+ [![License: PolyForm Noncommercial 1.0.0](https://img.shields.io/badge/License-PolyForm--Noncommercial--1.0.0-blue.svg)](https://github.com/yasyf/cookiesync/blob/main/LICENSE)
40
+
41
+ Sync your browser cookies across machines.
42
+
43
+ cookiesync copies the cookies your browser already holds on one machine and
44
+ replays them on another, so the sites you're signed into follow you between
45
+ laptops. It reuses your existing browser session instead of asking for
46
+ passwords again, so logins, 2FA, and SSO state carry over without you
47
+ re-authenticating anywhere.
48
+
49
+ > **macOS only.** cookiesync keeps your browser's Safe Storage key behind a
50
+ > Touch ID prompt and a Secure Enclave–bound daemon, so decrypted cookies never
51
+ > land on disk. The key helper is a Developer-ID-signed, notarized `.app`.
52
+
53
+ ## Install
54
+
55
+ cookiesync publishes on PyPI as `cookiesync-cli` and installs a `cookiesync`
56
+ command. You'll reach for it often, so install it onto your PATH with
57
+ [uv](https://docs.astral.sh/uv/):
58
+
59
+ ```bash
60
+ uv tool install cookiesync-cli
61
+ cookiesync --help
62
+ ```
63
+
64
+ To add it to a project instead:
65
+
66
+ ```bash
67
+ uv add cookiesync-cli
68
+ ```
69
+
70
+ ## Quickstart
71
+
72
+ ```bash
73
+ # Fetch the signed key helper and start the sync daemon (one time)
74
+ cookiesync install
75
+
76
+ # Confirm the helper is installed and Developer-ID signed
77
+ cookiesync doctor
78
+
79
+ # Track a browser to sync between this Mac and another host
80
+ cookiesync browser add other-host chrome
81
+
82
+ # Hand a logged-in session to a script without giving it a password
83
+ cookiesync cookies https://example.com --browser chrome
84
+ ```
85
+
86
+ Once a browser is tracked, the resident daemon watches its cookie store and
87
+ converges it across your hosts. Run `cookiesync reconcile` to force a full pass.
88
+
89
+ ## Commands
90
+
91
+ | Command | What it does |
92
+ | --- | --- |
93
+ | `install` | Fetch the signed key helper, then install the LaunchAgents (watch daemon + reconcile tick). |
94
+ | `uninstall` | Remove the cookiesync LaunchAgents. |
95
+ | `doctor` | Check that the key helper is installed and Developer-ID signed. |
96
+ | `browser add/ls/rm` | Track, list, and untrack the browser profiles cookiesync syncs across hosts. |
97
+ | `watch` | Run the resident sync daemon: watch local stores and serve the RPC socket. |
98
+ | `sync --browser <name>` | Converge one browser group across this host and its peers. |
99
+ | `reconcile` | Run a full reconcile pass over every tracked browser group. |
100
+ | `auth` | Release the Safe Storage key behind one Touch ID tap and cache it for a short window. |
101
+ | `cookies <url>` | Stream a URL's cookies in the chosen format (Playwright by default). |
102
+ | `self` | Print this host's SSH target, as reposync reports it. |
103
+ | `rpc <method>` | Low-level RPC client for the resident daemon. |
104
+
105
+ Run `cookiesync --help`, or `cookiesync <command> --help`, for the full reference.
106
+
107
+ ## What problems does this solve?
108
+
109
+ - A fresh machine means signing into every account again. cookiesync moves your
110
+ live browser session over, so you land already logged in.
111
+ - 2FA and SSO re-prompt whenever you switch laptops. Carrying the existing
112
+ cookies over keeps those sessions valid instead of restarting them.
113
+ - Built-in browser sync is all-or-nothing and locked to one vendor. cookiesync
114
+ is browser-agnostic, and you pick which machines and which sites it touches.
115
+ - Automation needs a logged-in session but should never hold a password. Hand a
116
+ CI job or an agent the cookies it needs instead of a credential it can leak.
117
+
118
+ ## License
119
+
120
+ PolyForm Noncommercial 1.0.0 — see [LICENSE](https://github.com/yasyf/cookiesync/blob/main/LICENSE).
@@ -0,0 +1,36 @@
1
+ cookiesync/__init__.py,sha256=HIzld7Ntk31OHzXkxxXwkfhqjmGmtYLkEbb-eOvvTp0,85
2
+ cookiesync/__main__.py,sha256=i43SB8UUcQlTQivUfDg070qDluA6jejDDit5Ot7DN0Y,107
3
+ cookiesync/cli.py,sha256=pGuiErDGIYgovjOPOKxk5_QDhElKe5MaB-9GLPa2I5U,12803
4
+ cookiesync/cookie/__init__.py,sha256=Z88UHD7508-2jj-8Y3EK4wqyyg4qljpUxOIfa0FF9LM,724
5
+ cookiesync/cookie/backend.py,sha256=lh5mOKsT4081_JLQbzm0gVytzHLI8kT3sfcUkL6lLA4,3830
6
+ cookiesync/cookie/browsers.py,sha256=grZsN9g25Gmc8E0mGvyo-v4n2MoCkBP_rPGCwnABxa8,1703
7
+ cookiesync/cookie/consent.py,sha256=uwCjmegDOENWY1zpIGAZpsad3B1J0Xv6bjSb9-Ph4Xw,5981
8
+ cookiesync/cookie/crypto.py,sha256=1W71mkOk8ezUcMwHbx64X2Bs1W0j2DxWZr-TqqMcJkQ,3431
9
+ cookiesync/cookie/domains.py,sha256=fDLJv0YSu1VU_TdIRN9FCw7sKOhiobcFPugOdUCVZwc,1332
10
+ cookiesync/cookie/getcookie.py,sha256=--qIgmXsavryL2MkmTZ78QZBOfRNRLmVAp42BPy_rF8,3938
11
+ cookiesync/cookie/merge.py,sha256=_wh8EXcFGaTkpl-JIhLt-ge9CZU0F8dEb3LyTQIEyvQ,2704
12
+ cookiesync/cookie/models.py,sha256=7CiYfnA-2X8x2No0Qb7IMV3wgEL6g45SQxuyY0prlv4,2607
13
+ cookiesync/cookie/pipeline.py,sha256=wPKIeWvejunOAZq_ce-cIiLDas-aae9QS6NjZOWPNgY,3715
14
+ cookiesync/cookie/serialize.py,sha256=In_eNP10_h5eRv5V1JOPWHGWnmk5SQ13c_wSHq8fdO8,4633
15
+ cookiesync/cookie/stores.py,sha256=ei4AdwZ8fPc--fdbLCm0lSnH6B8mKrHn57p5pwbuLe8,8857
16
+ cookiesync/daemon/__init__.py,sha256=wPxgMf_HCUANBYnp4hcri-gI2-HpKAwyVTqoLQim394,632
17
+ cookiesync/daemon/backend_ssh.py,sha256=qDJgVKKy1UveVHtUaK2Xke4i5dXOva8CuwpowwNIeHU,2972
18
+ cookiesync/daemon/cache.py,sha256=WAYcz_gL18IieGNxIRUTTQgM1yD_H7kbO44FTBUn490,4126
19
+ cookiesync/daemon/engine.py,sha256=qfrwNVjHMPu9jpNz-VrPcDDp4-SaAmQw6WRP4lus_zM,8683
20
+ cookiesync/daemon/rpc.py,sha256=IErhxA05fwZm6e1IR4dohUUlwMsohOXxkGHKIgqW3bs,6071
21
+ cookiesync/daemon/server.py,sha256=UmKhl_2SiPtzIw32M4lCu5ejWY3uxugvzz5GdhR9r4s,18334
22
+ cookiesync/daemon/session.py,sha256=oGLxLanMececgQjmm4L_Okx2JEDOElXzJskJyIIz1Ys,4275
23
+ cookiesync/daemon/sync.py,sha256=mYdEZ-5GIbqgazSfzKNrFRRMxU1w-9U-FfL6x9r9jKk,10111
24
+ cookiesync/daemon/wire.py,sha256=8HK0SQmc90yMonH1d55ze_EPeS5C132StusrhTdyAGw,2893
25
+ cookiesync/helper.py,sha256=b2Uu0o5LAA2SG45y-06HuqH6BZKRddMArt5HT9PiEoA,4864
26
+ cookiesync/paths.py,sha256=sWeSYrY9-pNhI6GRhZ9YBQNCZurKPkVUtkl28CtNBsY,3396
27
+ cookiesync/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ cookiesync/registry.py,sha256=wIR9IwAvFm5MHPRdGhoAHXg_JTzrB1fwO6JprtpqB1Y,2642
29
+ cookiesync/service.py,sha256=rLhyMZU2aTsGJIPuehKHhqlVFlGkVjJK0p65To4X2rg,7831
30
+ cookiesync/state.py,sha256=aEwSZSQvvLfOekYEa1xjC-tklAR51guV9hZ3mGVH3qw,5862
31
+ cookiesync/transport.py,sha256=H0GQRBh4sCokWssONdnBMCaOwkTvvbQdDvXD0pcD0GE,3421
32
+ cookiesync_cli-0.1.0.dist-info/licenses/LICENSE,sha256=WnXdIVx8s3iX4VERpwDy0Oys0X-DlqtDvn3vn_ELQC0,4646
33
+ cookiesync_cli-0.1.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
34
+ cookiesync_cli-0.1.0.dist-info/entry_points.txt,sha256=82BP10z9tY2BZaPYYQzlS_SoycNmKFJojDZRDe8cARs,52
35
+ cookiesync_cli-0.1.0.dist-info/METADATA,sha256=z1PqVLUtDejmuDI8hmcvfqAhuKkwDTKWJBmpHYS-39g,5010
36
+ cookiesync_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ cookiesync = cookiesync.cli:main
3
+