cookiesync-cli 0.1.0__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.
- cookiesync_cli-0.1.0/LICENSE +133 -0
- cookiesync_cli-0.1.0/PKG-INFO +120 -0
- cookiesync_cli-0.1.0/README.md +88 -0
- cookiesync_cli-0.1.0/cookiesync/__init__.py +3 -0
- cookiesync_cli-0.1.0/cookiesync/__main__.py +6 -0
- cookiesync_cli-0.1.0/cookiesync/cli.py +339 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/__init__.py +20 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/backend.py +100 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/browsers.py +57 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/consent.py +128 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/crypto.py +85 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/domains.py +38 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/getcookie.py +113 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/merge.py +74 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/models.py +90 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/pipeline.py +101 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/serialize.py +132 -0
- cookiesync_cli-0.1.0/cookiesync/cookie/stores.py +218 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/__init__.py +13 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/backend_ssh.py +70 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/cache.py +113 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/engine.py +195 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/rpc.py +153 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/server.py +378 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/session.py +117 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/sync.py +241 -0
- cookiesync_cli-0.1.0/cookiesync/daemon/wire.py +90 -0
- cookiesync_cli-0.1.0/cookiesync/helper.py +112 -0
- cookiesync_cli-0.1.0/cookiesync/paths.py +87 -0
- cookiesync_cli-0.1.0/cookiesync/py.typed +0 -0
- cookiesync_cli-0.1.0/cookiesync/registry.py +79 -0
- cookiesync_cli-0.1.0/cookiesync/service.py +214 -0
- cookiesync_cli-0.1.0/cookiesync/state.py +173 -0
- cookiesync_cli-0.1.0/cookiesync/transport.py +108 -0
- cookiesync_cli-0.1.0/pyproject.toml +104 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Required Notice: Copyright Yasyf Mohamedali (https://github.com/yasyf/cookiesync)
|
|
2
|
+
|
|
3
|
+
# PolyForm Noncommercial License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree
|
|
10
|
+
to them as both strict obligations and conditions to all
|
|
11
|
+
your licenses.
|
|
12
|
+
|
|
13
|
+
## Copyright License
|
|
14
|
+
|
|
15
|
+
The licensor grants you a copyright license for the
|
|
16
|
+
software to do everything you might do with the software
|
|
17
|
+
that would otherwise infringe the licensor's copyright
|
|
18
|
+
in it for any permitted purpose. However, you may
|
|
19
|
+
only distribute the software according to [Distribution
|
|
20
|
+
License](#distribution-license) and make changes or new works
|
|
21
|
+
based on the software according to [Changes and New Works
|
|
22
|
+
License](#changes-and-new-works-license).
|
|
23
|
+
|
|
24
|
+
## Distribution License
|
|
25
|
+
|
|
26
|
+
The licensor grants you an additional copyright license
|
|
27
|
+
to distribute copies of the software. Your license
|
|
28
|
+
to distribute covers distributing the software with
|
|
29
|
+
changes and new works permitted by [Changes and New Works
|
|
30
|
+
License](#changes-and-new-works-license).
|
|
31
|
+
|
|
32
|
+
## Notices
|
|
33
|
+
|
|
34
|
+
You must ensure that anyone who gets a copy of any part of
|
|
35
|
+
the software from you also gets a copy of these terms or the
|
|
36
|
+
URL for them above, as well as copies of any plain-text lines
|
|
37
|
+
beginning with `Required Notice:` that the licensor provided
|
|
38
|
+
with the software. For example:
|
|
39
|
+
|
|
40
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
41
|
+
|
|
42
|
+
## Changes and New Works License
|
|
43
|
+
|
|
44
|
+
The licensor grants you an additional copyright license to
|
|
45
|
+
make changes and new works based on the software for any
|
|
46
|
+
permitted purpose.
|
|
47
|
+
|
|
48
|
+
## Patent License
|
|
49
|
+
|
|
50
|
+
The licensor grants you a patent license for the software that
|
|
51
|
+
covers patent claims the licensor can license, or becomes able
|
|
52
|
+
to license, that you would infringe by using the software.
|
|
53
|
+
|
|
54
|
+
## Noncommercial Purposes
|
|
55
|
+
|
|
56
|
+
Any noncommercial purpose is a permitted purpose.
|
|
57
|
+
|
|
58
|
+
## Personal Uses
|
|
59
|
+
|
|
60
|
+
Personal use for research, experiment, and testing for
|
|
61
|
+
the benefit of public knowledge, personal study, private
|
|
62
|
+
entertainment, hobby projects, amateur pursuits, or religious
|
|
63
|
+
observance, without any anticipated commercial application,
|
|
64
|
+
is use for a permitted purpose.
|
|
65
|
+
|
|
66
|
+
## Noncommercial Organizations
|
|
67
|
+
|
|
68
|
+
Use by any charitable organization, educational institution,
|
|
69
|
+
public research organization, public safety or health
|
|
70
|
+
organization, environmental protection organization,
|
|
71
|
+
or government institution is use for a permitted purpose
|
|
72
|
+
regardless of the source of funding or obligations resulting
|
|
73
|
+
from the funding.
|
|
74
|
+
|
|
75
|
+
## Fair Use
|
|
76
|
+
|
|
77
|
+
You may have "fair use" rights for the software under the
|
|
78
|
+
law. These terms do not limit them.
|
|
79
|
+
|
|
80
|
+
## No Other Rights
|
|
81
|
+
|
|
82
|
+
These terms do not allow you to sublicense or transfer any of
|
|
83
|
+
your licenses to anyone else, or prevent the licensor from
|
|
84
|
+
granting licenses to anyone else. These terms do not imply
|
|
85
|
+
any other licenses.
|
|
86
|
+
|
|
87
|
+
## Patent Defense
|
|
88
|
+
|
|
89
|
+
If you make any written claim that the software infringes or
|
|
90
|
+
contributes to infringement of any patent, your patent license
|
|
91
|
+
for the software granted under these terms ends immediately. If
|
|
92
|
+
your company makes such a claim, your patent license ends
|
|
93
|
+
immediately for work on behalf of your company.
|
|
94
|
+
|
|
95
|
+
## Violations
|
|
96
|
+
|
|
97
|
+
The first time you are notified in writing that you have
|
|
98
|
+
violated any of these terms, or done anything with the software
|
|
99
|
+
not covered by your licenses, your licenses can nonetheless
|
|
100
|
+
continue if you come into full compliance with these terms,
|
|
101
|
+
and take practical steps to correct past violations, within
|
|
102
|
+
32 days of receiving notice. Otherwise, all your licenses
|
|
103
|
+
end immediately.
|
|
104
|
+
|
|
105
|
+
## No Liability
|
|
106
|
+
|
|
107
|
+
***As far as the law allows, the software comes as is, without
|
|
108
|
+
any warranty or condition, and the licensor will not be liable
|
|
109
|
+
to you for any damages arising out of these terms or the use
|
|
110
|
+
or nature of the software, under any kind of legal claim.***
|
|
111
|
+
|
|
112
|
+
## Definitions
|
|
113
|
+
|
|
114
|
+
The **licensor** is the individual or entity offering these
|
|
115
|
+
terms, and the **software** is the software the licensor makes
|
|
116
|
+
available under these terms.
|
|
117
|
+
|
|
118
|
+
**You** refers to the individual or entity agreeing to these
|
|
119
|
+
terms.
|
|
120
|
+
|
|
121
|
+
**Your company** is any legal entity, sole proprietorship,
|
|
122
|
+
or other kind of organization that you work for, plus all
|
|
123
|
+
organizations that have control over, are under the control of,
|
|
124
|
+
or are under common control with that organization. **Control**
|
|
125
|
+
means ownership of substantially all the assets of an entity,
|
|
126
|
+
or the power to direct its management and policies by vote,
|
|
127
|
+
contract, or otherwise. Control can be direct or indirect.
|
|
128
|
+
|
|
129
|
+
**Your licenses** are all the licenses granted to you for the
|
|
130
|
+
software under these terms.
|
|
131
|
+
|
|
132
|
+
**Use** means anything you do with the software requiring one
|
|
133
|
+
of your licenses.
|
|
@@ -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
|
+

|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/cookiesync-cli/)
|
|
38
|
+
[](https://pypi.org/project/cookiesync-cli/)
|
|
39
|
+
[](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,88 @@
|
|
|
1
|
+
# cookiesync
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/cookiesync-cli/)
|
|
6
|
+
[](https://pypi.org/project/cookiesync-cli/)
|
|
7
|
+
[](https://github.com/yasyf/cookiesync/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
Sync your browser cookies across machines.
|
|
10
|
+
|
|
11
|
+
cookiesync copies the cookies your browser already holds on one machine and
|
|
12
|
+
replays them on another, so the sites you're signed into follow you between
|
|
13
|
+
laptops. It reuses your existing browser session instead of asking for
|
|
14
|
+
passwords again, so logins, 2FA, and SSO state carry over without you
|
|
15
|
+
re-authenticating anywhere.
|
|
16
|
+
|
|
17
|
+
> **macOS only.** cookiesync keeps your browser's Safe Storage key behind a
|
|
18
|
+
> Touch ID prompt and a Secure Enclave–bound daemon, so decrypted cookies never
|
|
19
|
+
> land on disk. The key helper is a Developer-ID-signed, notarized `.app`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
cookiesync publishes on PyPI as `cookiesync-cli` and installs a `cookiesync`
|
|
24
|
+
command. You'll reach for it often, so install it onto your PATH with
|
|
25
|
+
[uv](https://docs.astral.sh/uv/):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv tool install cookiesync-cli
|
|
29
|
+
cookiesync --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
To add it to a project instead:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add cookiesync-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Fetch the signed key helper and start the sync daemon (one time)
|
|
42
|
+
cookiesync install
|
|
43
|
+
|
|
44
|
+
# Confirm the helper is installed and Developer-ID signed
|
|
45
|
+
cookiesync doctor
|
|
46
|
+
|
|
47
|
+
# Track a browser to sync between this Mac and another host
|
|
48
|
+
cookiesync browser add other-host chrome
|
|
49
|
+
|
|
50
|
+
# Hand a logged-in session to a script without giving it a password
|
|
51
|
+
cookiesync cookies https://example.com --browser chrome
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Once a browser is tracked, the resident daemon watches its cookie store and
|
|
55
|
+
converges it across your hosts. Run `cookiesync reconcile` to force a full pass.
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
| Command | What it does |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `install` | Fetch the signed key helper, then install the LaunchAgents (watch daemon + reconcile tick). |
|
|
62
|
+
| `uninstall` | Remove the cookiesync LaunchAgents. |
|
|
63
|
+
| `doctor` | Check that the key helper is installed and Developer-ID signed. |
|
|
64
|
+
| `browser add/ls/rm` | Track, list, and untrack the browser profiles cookiesync syncs across hosts. |
|
|
65
|
+
| `watch` | Run the resident sync daemon: watch local stores and serve the RPC socket. |
|
|
66
|
+
| `sync --browser <name>` | Converge one browser group across this host and its peers. |
|
|
67
|
+
| `reconcile` | Run a full reconcile pass over every tracked browser group. |
|
|
68
|
+
| `auth` | Release the Safe Storage key behind one Touch ID tap and cache it for a short window. |
|
|
69
|
+
| `cookies <url>` | Stream a URL's cookies in the chosen format (Playwright by default). |
|
|
70
|
+
| `self` | Print this host's SSH target, as reposync reports it. |
|
|
71
|
+
| `rpc <method>` | Low-level RPC client for the resident daemon. |
|
|
72
|
+
|
|
73
|
+
Run `cookiesync --help`, or `cookiesync <command> --help`, for the full reference.
|
|
74
|
+
|
|
75
|
+
## What problems does this solve?
|
|
76
|
+
|
|
77
|
+
- A fresh machine means signing into every account again. cookiesync moves your
|
|
78
|
+
live browser session over, so you land already logged in.
|
|
79
|
+
- 2FA and SSO re-prompt whenever you switch laptops. Carrying the existing
|
|
80
|
+
cookies over keeps those sessions valid instead of restarting them.
|
|
81
|
+
- Built-in browser sync is all-or-nothing and locked to one vendor. cookiesync
|
|
82
|
+
is browser-agnostic, and you pick which machines and which sites it touches.
|
|
83
|
+
- Automation needs a logged-in session but should never hold a password. Hand a
|
|
84
|
+
CI job or an agent the cookies it needs instead of a credential it can leak.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
PolyForm Noncommercial 1.0.0 — see [LICENSE](https://github.com/yasyf/cookiesync/blob/main/LICENSE).
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import click
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from cookiesync import helper, paths, state
|
|
12
|
+
from cookiesync.cookie import OutputFormat, StorageState, render
|
|
13
|
+
from cookiesync.cookie.browsers import REGISTRY
|
|
14
|
+
from cookiesync.daemon import rpc
|
|
15
|
+
from cookiesync.daemon.rpc import RpcError
|
|
16
|
+
from cookiesync.daemon.wire import cookie_from_wire
|
|
17
|
+
from cookiesync.helper import HelperState
|
|
18
|
+
from cookiesync.registry import RegistryError, reposync_registry, reposync_self
|
|
19
|
+
from cookiesync.state import BrowserEndpoint, BrowserId, SshTarget, parse_duration
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from cookiesync.daemon.wire import Response
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.version_option(package_name="cookiesync")
|
|
27
|
+
def main() -> None:
|
|
28
|
+
"""Sync your browser cookies across machines."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def daemon_call(method: str, params: dict | None = None) -> dict | list | None:
|
|
32
|
+
"""Call ``method`` on the resident daemon, raising a clean :class:`click.ClickException` on failure."""
|
|
33
|
+
try:
|
|
34
|
+
response = await rpc.call(method, params or {})
|
|
35
|
+
except RpcError as exc:
|
|
36
|
+
raise click.ClickException(f"{exc}; is the daemon running? (cookiesync install)") from exc
|
|
37
|
+
return response_result(response)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def response_result(response: Response) -> dict | list | None:
|
|
41
|
+
if not response.ok:
|
|
42
|
+
raise click.ClickException(response.error or "daemon error")
|
|
43
|
+
return response.result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@main.group()
|
|
47
|
+
def browser() -> None:
|
|
48
|
+
"""Track the browser profiles cookiesync syncs across hosts."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@browser.command("add")
|
|
52
|
+
@click.argument("host")
|
|
53
|
+
@click.argument("browser_name")
|
|
54
|
+
@click.option("--profile", default="Default", help="Profile directory name.")
|
|
55
|
+
def browser_add(host: str, browser_name: str, profile: str) -> None:
|
|
56
|
+
"""Track a browser profile on HOST for syncing."""
|
|
57
|
+
anyio.run(add_endpoint, SshTarget(host), browser_name, profile)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@browser.command("ls")
|
|
61
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the endpoints as JSON.")
|
|
62
|
+
def browser_ls(as_json: bool) -> None:
|
|
63
|
+
"""List the tracked browser endpoints."""
|
|
64
|
+
anyio.run(list_endpoints, as_json)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@browser.command("rm")
|
|
68
|
+
@click.argument("host")
|
|
69
|
+
@click.argument("browser_name")
|
|
70
|
+
@click.option("--profile", default="Default", help="Profile directory name.")
|
|
71
|
+
def browser_rm(host: str, browser_name: str, profile: str) -> None:
|
|
72
|
+
"""Stop tracking a browser profile on HOST."""
|
|
73
|
+
anyio.run(remove_endpoint, SshTarget(host), browser_name, profile)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def add_endpoint(host: SshTarget, browser_name: str, profile: str) -> None:
|
|
77
|
+
if browser_name not in REGISTRY:
|
|
78
|
+
raise click.ClickException(f"unknown browser {browser_name!r}; choose from {', '.join(sorted(REGISTRY))}")
|
|
79
|
+
try:
|
|
80
|
+
self_target, hosts = await reposync_registry()
|
|
81
|
+
except RegistryError as exc:
|
|
82
|
+
raise click.ClickException(str(exc)) from exc
|
|
83
|
+
if host != self_target and host not in hosts:
|
|
84
|
+
raise click.ClickException(f"unknown host {host!r}; choose from {', '.join((self_target, *hosts))}")
|
|
85
|
+
endpoint = BrowserEndpoint(host, BrowserId(browser_name), profile)
|
|
86
|
+
await state.update(
|
|
87
|
+
lambda s: replace(
|
|
88
|
+
s,
|
|
89
|
+
self_target=self_target,
|
|
90
|
+
browsers=(*(e for e in s.browsers if e.id != endpoint.id), endpoint),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
logger.debug("tracked {}", endpoint.id)
|
|
94
|
+
click.echo(f"Tracking {endpoint.id}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def list_endpoints(as_json: bool) -> None:
|
|
98
|
+
browsers = (await state.load()).browsers
|
|
99
|
+
if as_json:
|
|
100
|
+
click.echo(json.dumps([endpoint.to_json() for endpoint in browsers], indent=2))
|
|
101
|
+
return
|
|
102
|
+
click.echo("\n".join(endpoint.id for endpoint in browsers) if browsers else "No tracked browsers.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def remove_endpoint(host: SshTarget, browser_name: str, profile: str) -> None:
|
|
106
|
+
target = BrowserEndpoint(host, BrowserId(browser_name), profile).id
|
|
107
|
+
await state.update(lambda s: replace(s, browsers=tuple(e for e in s.browsers if e.id != target)))
|
|
108
|
+
logger.debug("untracked {}", target)
|
|
109
|
+
click.echo(f"Untracked {target}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@main.command()
|
|
113
|
+
def watch() -> None:
|
|
114
|
+
"""Run the resident sync daemon: watch local stores and serve the RPC socket."""
|
|
115
|
+
anyio.run(run_watch)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def run_watch() -> None:
|
|
119
|
+
from cookiesync.daemon import Daemon
|
|
120
|
+
|
|
121
|
+
logger.debug("starting cookiesync daemon")
|
|
122
|
+
await (await Daemon.build()).watch()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@main.command()
|
|
126
|
+
@click.option("--tick-only", is_flag=True, help="Install only the periodic reconcile tick, not the watch daemon.")
|
|
127
|
+
def install(tick_only: bool) -> None:
|
|
128
|
+
"""Fetch the signed key helper, then install the cookiesync LaunchAgents (watch daemon and reconcile tick)."""
|
|
129
|
+
anyio.run(run_install, tick_only)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def run_install(tick_only: bool) -> None:
|
|
133
|
+
from cookiesync.service import LaunchctlLauncher, install
|
|
134
|
+
|
|
135
|
+
await ensure_helper()
|
|
136
|
+
await install(LaunchctlLauncher(), tick_only=tick_only)
|
|
137
|
+
click.echo("Installed cookiesync agents." if not tick_only else "Installed the cookiesync reconcile tick.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def ensure_helper() -> None:
|
|
141
|
+
match await helper.helper_state():
|
|
142
|
+
case HelperState.OK:
|
|
143
|
+
click.echo(f"Key helper present and Developer-ID-signed: {paths.helper_app_path()}")
|
|
144
|
+
case _:
|
|
145
|
+
click.echo(
|
|
146
|
+
"Installing the signed key helper via Homebrew (brew install yasyf/tap/cookiesync-keyhelper)…", err=True
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
app = await helper.install_helper()
|
|
150
|
+
except helper.HelperInstallError as exc:
|
|
151
|
+
raise click.ClickException(str(exc)) from exc
|
|
152
|
+
click.echo(f"Installed and verified key helper: {app}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@main.command()
|
|
156
|
+
def doctor() -> None:
|
|
157
|
+
"""Check that the signed Secure-Enclave key helper is installed and Developer-ID-signed."""
|
|
158
|
+
anyio.run(run_doctor)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def run_doctor() -> None:
|
|
162
|
+
match await helper.helper_state():
|
|
163
|
+
case HelperState.OK if await helper.supports_contract():
|
|
164
|
+
click.echo(f"key helper OK: {paths.helper_app_path()} (Developer ID signed, key-helper contract supported)")
|
|
165
|
+
case HelperState.OK:
|
|
166
|
+
raise click.ClickException(
|
|
167
|
+
f"key helper at {paths.helper_app_path()} is installed but does not support the required "
|
|
168
|
+
"key-helper contract (likely a stale cask); reinstall the key helper: cookiesync install"
|
|
169
|
+
)
|
|
170
|
+
case HelperState.UNSIGNED:
|
|
171
|
+
raise click.ClickException(
|
|
172
|
+
f"key helper at {paths.helper_app_path()} is not Developer-ID-signed; "
|
|
173
|
+
"reinstall the notarized .app via 'cookiesync install'"
|
|
174
|
+
)
|
|
175
|
+
case HelperState.MISSING:
|
|
176
|
+
raise click.ClickException(
|
|
177
|
+
f"key helper not installed at {paths.helper_app_path()}; run 'cookiesync install' to fetch it"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@main.command()
|
|
182
|
+
def uninstall() -> None:
|
|
183
|
+
"""Remove the cookiesync LaunchAgents."""
|
|
184
|
+
anyio.run(run_uninstall)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def run_uninstall() -> None:
|
|
188
|
+
from cookiesync.service import LaunchctlLauncher, uninstall
|
|
189
|
+
|
|
190
|
+
await uninstall(LaunchctlLauncher())
|
|
191
|
+
click.echo("Uninstalled cookiesync agents.")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@main.command()
|
|
195
|
+
def reconcile() -> None:
|
|
196
|
+
"""Ask the daemon to run a full reconcile pass over every tracked browser group."""
|
|
197
|
+
anyio.run(run_reconcile)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def run_reconcile() -> None:
|
|
201
|
+
result = await daemon_call("reconcile")
|
|
202
|
+
click.echo(json.dumps(result, indent=2))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@main.command("sync")
|
|
206
|
+
@click.option("--browser", "browser_name", required=True, help="The browser group to converge.")
|
|
207
|
+
def sync_cmd(browser_name: str) -> None:
|
|
208
|
+
"""Ask the daemon to converge one browser group across this host and its peers."""
|
|
209
|
+
anyio.run(run_sync, browser_name)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def run_sync(browser_name: str) -> None:
|
|
213
|
+
result = await daemon_call("sync", {"browser": browser_name})
|
|
214
|
+
click.echo(json.dumps(result, indent=2))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@main.command()
|
|
218
|
+
@click.option("--browser", "browser_name", default="chrome", show_default=True, help="The browser to authenticate.")
|
|
219
|
+
@click.option("--profile", default="Default", show_default=True, help="The profile to authenticate.")
|
|
220
|
+
@click.option("--ttl", default=None, help="Override the cache TTL (Go-style duration, e.g. 15m).")
|
|
221
|
+
def auth(browser_name: str, profile: str, ttl: str | None) -> None:
|
|
222
|
+
"""Release the Safe Storage key behind one Touch ID tap and cache it for a short window."""
|
|
223
|
+
anyio.run(run_auth, browser_name, profile, ttl)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def run_auth(browser_name: str, profile: str, ttl: str | None) -> None:
|
|
227
|
+
if ttl is not None:
|
|
228
|
+
await state.update(lambda s: replace(s, settings=replace(s.settings, auth_ttl=parse_duration(ttl))))
|
|
229
|
+
result = await daemon_call("prime_auth", {"browser": browser_name, "profile": profile})
|
|
230
|
+
click.echo(f"Authenticated {result['endpoint']}.")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@main.command()
|
|
234
|
+
@click.argument("url")
|
|
235
|
+
@click.option(
|
|
236
|
+
"--browser", "browser_name", default="chrome", show_default=True, help="The browser to read cookies from."
|
|
237
|
+
)
|
|
238
|
+
@click.option("--profile", default="Default", show_default=True, help="The profile to read cookies from.")
|
|
239
|
+
@click.option(
|
|
240
|
+
"--format",
|
|
241
|
+
"fmt",
|
|
242
|
+
type=click.Choice([f.value for f in OutputFormat]),
|
|
243
|
+
default=OutputFormat.PLAYWRIGHT.value,
|
|
244
|
+
show_default=True,
|
|
245
|
+
help="The output wire format.",
|
|
246
|
+
)
|
|
247
|
+
def cookies(url: str, browser_name: str, profile: str, fmt: str) -> None:
|
|
248
|
+
"""Stream URL's cookies in the chosen format, decrypting with the daemon's cached key."""
|
|
249
|
+
anyio.run(run_cookies, url, browser_name, profile, fmt)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def run_cookies(url: str, browser_name: str, profile: str, fmt: str) -> None:
|
|
253
|
+
result = await daemon_call("get_cookies", {"url": url, "browser": browser_name, "profile": profile})
|
|
254
|
+
state_obj = StorageState(tuple(cookie_from_wire(c) for c in result["cookies"]))
|
|
255
|
+
for line in render(state_obj, OutputFormat(fmt)):
|
|
256
|
+
click.echo(line)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@main.group()
|
|
260
|
+
def rpc_group() -> None:
|
|
261
|
+
"""Low-level RPC client: drive the resident daemon over its unix socket."""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
main.add_command(rpc_group, name="rpc")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@rpc_group.command("extract")
|
|
268
|
+
@click.option("--browser", "browser_name", required=True)
|
|
269
|
+
@click.option("--profile", default="Default")
|
|
270
|
+
@click.option("--origin", default=None)
|
|
271
|
+
def rpc_extract(browser_name: str, profile: str, origin: str | None) -> None:
|
|
272
|
+
"""Return this host's decrypted cookies for a browser as wire records (used by peers over ssh)."""
|
|
273
|
+
anyio.run(run_rpc_passthrough, "extract", {"browser": browser_name, "profile": profile, "origin": origin})
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@rpc_group.command("apply")
|
|
277
|
+
@click.option("--browser", "browser_name", required=True)
|
|
278
|
+
@click.option("--profile", default="Default")
|
|
279
|
+
@click.option("--origin", default=None)
|
|
280
|
+
def rpc_apply(browser_name: str, profile: str, origin: str | None) -> None:
|
|
281
|
+
"""Ingest a merged wire cookie array from stdin into this host's store (used by peers over ssh)."""
|
|
282
|
+
cookies_in = json.loads(click.get_text_stream("stdin").read())
|
|
283
|
+
anyio.run(
|
|
284
|
+
run_rpc_passthrough,
|
|
285
|
+
"apply",
|
|
286
|
+
{"browser": browser_name, "profile": profile, "origin": origin, "cookies": cookies_in},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@rpc_group.command("sync")
|
|
291
|
+
@click.option("--browser", "browser_name", required=True)
|
|
292
|
+
@click.option("--origin", default=None)
|
|
293
|
+
def rpc_sync(browser_name: str, origin: str | None) -> None:
|
|
294
|
+
"""Ask the daemon to converge one browser group, tagged with the notifying peer's origin."""
|
|
295
|
+
anyio.run(run_rpc_passthrough, "sync", {"browser": browser_name, "origin": origin})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@rpc_group.command("reconcile")
|
|
299
|
+
def rpc_reconcile() -> None:
|
|
300
|
+
"""Ask the daemon to run a full reconcile pass."""
|
|
301
|
+
anyio.run(run_rpc_passthrough, "reconcile", {})
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@rpc_group.command("whoami")
|
|
305
|
+
def rpc_whoami() -> None:
|
|
306
|
+
"""Report this host's console session state."""
|
|
307
|
+
anyio.run(run_rpc_passthrough, "whoami", {})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@rpc_group.command("request_consent")
|
|
311
|
+
@click.option("--browser", "browser_name", required=True)
|
|
312
|
+
@click.option("--profile", default="Default")
|
|
313
|
+
@click.option("--nonce", required=True)
|
|
314
|
+
@click.option("--endpoint", required=True)
|
|
315
|
+
def rpc_request_consent(browser_name: str, profile: str, nonce: str, endpoint: str) -> None:
|
|
316
|
+
"""Show the Touch ID prompt for BROWSER here and echo the requester's nonce + endpoint."""
|
|
317
|
+
anyio.run(
|
|
318
|
+
run_rpc_passthrough,
|
|
319
|
+
"request_consent",
|
|
320
|
+
{"browser": browser_name, "profile": profile, "nonce": nonce, "endpoint": endpoint},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def run_rpc_passthrough(method: str, params: dict) -> None:
|
|
325
|
+
click.echo(json.dumps(await daemon_call(method, params)))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@main.command(name="self")
|
|
329
|
+
def self_cmd() -> None:
|
|
330
|
+
"""Print this host's own SSH target, as reposync reports it."""
|
|
331
|
+
anyio.run(run_self)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def run_self() -> None:
|
|
335
|
+
try:
|
|
336
|
+
target = await reposync_self()
|
|
337
|
+
except RegistryError as exc:
|
|
338
|
+
raise click.ClickException(str(exc)) from exc
|
|
339
|
+
click.echo(target)
|