cookiesync-cli 0.1.1__tar.gz → 0.1.3__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.
Files changed (35) hide show
  1. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/PKG-INFO +29 -38
  2. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/README.md +28 -37
  3. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cli.py +9 -4
  4. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/consent.py +3 -3
  5. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/engine.py +12 -2
  6. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/server.py +7 -5
  7. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/helper.py +8 -2
  8. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/pyproject.toml +1 -1
  9. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/LICENSE +0 -0
  10. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/__init__.py +0 -0
  11. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/__main__.py +0 -0
  12. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/__init__.py +0 -0
  13. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/backend.py +0 -0
  14. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/browsers.py +0 -0
  15. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/crypto.py +0 -0
  16. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/domains.py +0 -0
  17. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/getcookie.py +0 -0
  18. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/merge.py +0 -0
  19. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/models.py +0 -0
  20. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/pipeline.py +0 -0
  21. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/serialize.py +0 -0
  22. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/stores.py +0 -0
  23. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/__init__.py +0 -0
  24. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/backend_ssh.py +0 -0
  25. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/cache.py +0 -0
  26. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/rpc.py +0 -0
  27. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/session.py +0 -0
  28. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/sync.py +0 -0
  29. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/wire.py +0 -0
  30. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/paths.py +0 -0
  31. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/py.typed +0 -0
  32. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/registry.py +0 -0
  33. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/service.py +0 -0
  34. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/state.py +0 -0
  35. {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cookiesync-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Sync your browser cookies across machines.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -38,53 +38,46 @@ Description-Content-Type: text/markdown
38
38
  [![Python](https://img.shields.io/pypi/pyversions/cookiesync-cli.svg)](https://pypi.org/project/cookiesync-cli/)
39
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
40
 
41
- Sync your browser cookies across machines.
41
+ Sync your browser cookies across machines — land on a new laptop already logged in.
42
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.
43
+ cookiesync copies the cookies your browser already holds on one machine and replays
44
+ them on another, reusing your live session instead of asking for passwords again — so
45
+ logins, 2FA, and SSO state carry over without re-authenticating. It's browser-agnostic,
46
+ and you pick which machines and which sites it touches. Automation can borrow a
47
+ logged-in session too: hand a CI job or an agent the cookies it needs, never a password.
48
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`.
49
+ > **macOS only.** cookiesync keeps your browser's Safe Storage key behind a Touch ID
50
+ > prompt and a Secure Enclave–bound daemon, so decrypted cookies never land on disk.
51
+ > The key helper is a Developer-ID-signed, notarized `.app`.
52
52
 
53
53
  ## Install
54
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/):
55
+ Install the `cookiesync` command with [uv](https://docs.astral.sh/uv/):
58
56
 
59
57
  ```bash
60
58
  uv tool install cookiesync-cli
61
59
  cookiesync --help
62
60
  ```
63
61
 
64
- To add it to a project instead:
65
-
66
- ```bash
67
- uv add cookiesync-cli
68
- ```
69
-
70
62
  ## Quickstart
71
63
 
72
64
  ```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
65
+ # Fetch the signed key helper and install the LaunchAgents (one time)
66
+ $ cookiesync install
67
+ Installing the signed key helper via Homebrew (brew install yasyf/tap/cookiesync-keyhelper)…
68
+ Installed and verified key helper: /Applications/cookiesync-keyhelper.app
69
+ Installed cookiesync agents.
78
70
 
79
71
  # Track a browser to sync between this Mac and another host
80
- cookiesync browser add other-host chrome
72
+ $ cookiesync browser add other-host chrome
73
+ Tracking other-host/chrome/Default
81
74
 
82
- # Hand a logged-in session to a script without giving it a password
83
- cookiesync cookies https://example.com --browser chrome
75
+ # Hand a logged-in session to a script no password
76
+ $ cookiesync cookies https://example.com --browser chrome
84
77
  ```
85
78
 
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.
79
+ Once a browser is tracked, the resident daemon watches its cookie store and converges
80
+ it across your hosts. Run `cookiesync reconcile` to force a full pass.
88
81
 
89
82
  ## Commands
90
83
 
@@ -104,16 +97,14 @@ converges it across your hosts. Run `cookiesync reconcile` to force a full pass.
104
97
 
105
98
  Run `cookiesync --help`, or `cookiesync <command> --help`, for the full reference.
106
99
 
107
- ## What problems does this solve?
100
+ ## How it works
108
101
 
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.
102
+ `cookiesync install` fetches the notarized key helper and starts a resident daemon. The
103
+ daemon watches each tracked browser's cookie store, and on a change it converges that
104
+ group across your hosts over SSH extracting and re-applying cookies through the same
105
+ RPC the peers speak. Decryption needs the browser's Safe Storage key, which the helper
106
+ releases only behind a Touch ID tap (`cookiesync auth`) and caches in the
107
+ Secure-Enclave-bound daemon for a short window, never on disk.
117
108
 
118
109
  ## License
119
110
 
@@ -6,53 +6,46 @@
6
6
  [![Python](https://img.shields.io/pypi/pyversions/cookiesync-cli.svg)](https://pypi.org/project/cookiesync-cli/)
7
7
  [![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)
8
8
 
9
- Sync your browser cookies across machines.
9
+ Sync your browser cookies across machines — land on a new laptop already logged in.
10
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.
11
+ cookiesync copies the cookies your browser already holds on one machine and replays
12
+ them on another, reusing your live session instead of asking for passwords again — so
13
+ logins, 2FA, and SSO state carry over without re-authenticating. It's browser-agnostic,
14
+ and you pick which machines and which sites it touches. Automation can borrow a
15
+ logged-in session too: hand a CI job or an agent the cookies it needs, never a password.
16
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`.
17
+ > **macOS only.** cookiesync keeps your browser's Safe Storage key behind a Touch ID
18
+ > prompt and a Secure Enclave–bound daemon, so decrypted cookies never land on disk.
19
+ > The key helper is a Developer-ID-signed, notarized `.app`.
20
20
 
21
21
  ## Install
22
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/):
23
+ Install the `cookiesync` command with [uv](https://docs.astral.sh/uv/):
26
24
 
27
25
  ```bash
28
26
  uv tool install cookiesync-cli
29
27
  cookiesync --help
30
28
  ```
31
29
 
32
- To add it to a project instead:
33
-
34
- ```bash
35
- uv add cookiesync-cli
36
- ```
37
-
38
30
  ## Quickstart
39
31
 
40
32
  ```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
33
+ # Fetch the signed key helper and install the LaunchAgents (one time)
34
+ $ cookiesync install
35
+ Installing the signed key helper via Homebrew (brew install yasyf/tap/cookiesync-keyhelper)…
36
+ Installed and verified key helper: /Applications/cookiesync-keyhelper.app
37
+ Installed cookiesync agents.
46
38
 
47
39
  # Track a browser to sync between this Mac and another host
48
- cookiesync browser add other-host chrome
40
+ $ cookiesync browser add other-host chrome
41
+ Tracking other-host/chrome/Default
49
42
 
50
- # Hand a logged-in session to a script without giving it a password
51
- cookiesync cookies https://example.com --browser chrome
43
+ # Hand a logged-in session to a script no password
44
+ $ cookiesync cookies https://example.com --browser chrome
52
45
  ```
53
46
 
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.
47
+ Once a browser is tracked, the resident daemon watches its cookie store and converges
48
+ it across your hosts. Run `cookiesync reconcile` to force a full pass.
56
49
 
57
50
  ## Commands
58
51
 
@@ -72,16 +65,14 @@ converges it across your hosts. Run `cookiesync reconcile` to force a full pass.
72
65
 
73
66
  Run `cookiesync --help`, or `cookiesync <command> --help`, for the full reference.
74
67
 
75
- ## What problems does this solve?
68
+ ## How it works
76
69
 
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.
70
+ `cookiesync install` fetches the notarized key helper and starts a resident daemon. The
71
+ daemon watches each tracked browser's cookie store, and on a change it converges that
72
+ group across your hosts over SSH extracting and re-applying cookies through the same
73
+ RPC the peers speak. Decryption needs the browser's Safe Storage key, which the helper
74
+ releases only behind a Touch ID tap (`cookiesync auth`) and caches in the
75
+ Secure-Enclave-bound daemon for a short window, never on disk.
85
76
 
86
77
  ## License
87
78
 
@@ -119,6 +119,9 @@ async def run_watch() -> None:
119
119
  from cookiesync.daemon import Daemon
120
120
 
121
121
  logger.debug("starting cookiesync daemon")
122
+ # The daemon needs the signed helper for the SE key vault and cache; install it
123
+ # if absent so the helper is fully CLI-managed (the user never runs brew by hand).
124
+ await ensure_helper()
122
125
  await (await Daemon.build()).watch()
123
126
 
124
127
 
@@ -217,16 +220,18 @@ async def run_sync(browser_name: str) -> None:
217
220
  @main.command()
218
221
  @click.option("--browser", "browser_name", default="chrome", show_default=True, help="The browser to authenticate.")
219
222
  @click.option("--profile", default="Default", show_default=True, help="The profile to authenticate.")
223
+ @click.option("--reason", default=None, help="What the Touch ID prompt should say you're unlocking the cookies to do.")
220
224
  @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:
225
+ def auth(browser_name: str, profile: str, reason: str | None, ttl: str | None) -> None:
222
226
  """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)
227
+ anyio.run(run_auth, browser_name, profile, reason, ttl)
224
228
 
225
229
 
226
- async def run_auth(browser_name: str, profile: str, ttl: str | None) -> None:
230
+ async def run_auth(browser_name: str, profile: str, reason: str | None, ttl: str | None) -> None:
227
231
  if ttl is not None:
228
232
  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})
233
+ params = {"browser": browser_name, "profile": profile} | ({"reason": reason} if reason else {})
234
+ result = await daemon_call("prime_auth", params)
230
235
  click.echo(f"Authenticated {result['endpoint']}.")
231
236
 
232
237
 
@@ -46,12 +46,12 @@ class ConsentError(Exception):
46
46
 
47
47
 
48
48
  def compose_reason(host: str, reason: str) -> str:
49
- """Touch ID prompt text: domain first, the caller's reason as a 'to …' clause.
49
+ """Touch ID prompt text: concise and specific what is unlocked, then a short why.
50
50
 
51
51
  ``reason`` is collapsed to a single line and capped, since it surfaces verbatim in
52
- a security dialog.
52
+ the Touch ID dialog.
53
53
  """
54
- return f"access your {host} session to {' '.join(reason.split())[:REASON_CAP]}"
54
+ return f"unlock your {host} cookies to {' '.join(reason.split())[:REASON_CAP]}"
55
55
 
56
56
 
57
57
  class Consent(Protocol):
@@ -32,6 +32,7 @@ from dataclasses import dataclass, field
32
32
  from typing import TYPE_CHECKING, NewType, Protocol
33
33
 
34
34
  import anyio
35
+ from loguru import logger
35
36
 
36
37
  from cookiesync.cookie import REGISTRY
37
38
  from cookiesync.cookie.browsers import BrowserName
@@ -101,10 +102,19 @@ async def cookies_mtime(endpoint: BrowserEndpoint) -> float:
101
102
 
102
103
 
103
104
  async def watch_endpoint(endpoint: BrowserEndpoint) -> AsyncIterator[object]:
104
- """Yield once per ``watchfiles`` change batch on an endpoint's profile directory."""
105
+ """Yield once per ``watchfiles`` change batch on an endpoint's profile directory.
106
+
107
+ A local endpoint whose profile directory does not exist yet — e.g. a registered sync
108
+ target this host has not created — has nothing to watch. Log and return rather than let
109
+ ``watchfiles`` raise ``FileNotFoundError`` and tear the whole daemon down.
110
+ """
105
111
  from watchfiles import awatch
106
112
 
107
- async for changes in awatch(watch_dir(endpoint)):
113
+ directory = watch_dir(endpoint)
114
+ if not await anyio.Path(str(directory)).exists():
115
+ logger.warning("not watching {}: profile dir {} does not exist", endpoint.id, directory)
116
+ return
117
+ async for changes in awatch(directory):
108
118
  yield changes
109
119
 
110
120
 
@@ -65,7 +65,7 @@ if TYPE_CHECKING:
65
65
  PEER_METHODS = ("sync", "reconcile", "extract", "apply", "whoami")
66
66
  LOCAL_METHODS = ("prime_auth", "get_cookies", "auth_status", "request_consent")
67
67
 
68
- CONSENT_REASON = "sync your cookies across your machines"
68
+ CONSENT_REASON = "sync them across your Macs"
69
69
  DEFAULT_PROFILE = "Default"
70
70
 
71
71
 
@@ -274,10 +274,12 @@ class Daemon:
274
274
  state = await self.load_state()
275
275
  browser = BrowserId(params["browser"])
276
276
  profile = params.get("profile", DEFAULT_PROFILE)
277
- await self.prime_auth(browser, profile, state)
277
+ await self.prime_auth(browser, profile, state, reason=params.get("reason") or CONSENT_REASON)
278
278
  return {"primed": True, "endpoint": endpoint_id(state.self_target, browser, profile)}
279
279
 
280
- async def prime_auth(self, browser: BrowserId, profile: str, state: State) -> AesKey:
280
+ async def prime_auth(
281
+ self, browser: BrowserId, profile: str, state: State, *, reason: str = CONSENT_REASON
282
+ ) -> AesKey:
281
283
  """Obtain the Safe Storage key and cache it under the endpoint's TTL.
282
284
 
283
285
  A live local session releases the key behind one Touch-ID tap here. Otherwise the user
@@ -288,7 +290,7 @@ class Daemon:
288
290
  :class:`AuthRequired` when no peer can approve or the reply fails to bind.
289
291
  """
290
292
  if await has_active_session(probe=self.probe):
291
- key = await self.consent.obtain_key(browser_for(browser), reason=CONSENT_REASON)
293
+ key = await self.consent.obtain_key(browser_for(browser), reason=reason)
292
294
  else:
293
295
  key = await self.routed_release(browser, profile, state)
294
296
  await self.cache.put(
@@ -368,7 +370,7 @@ class Daemon:
368
370
  if not await has_active_session(probe=self.probe):
369
371
  return {"status": "unavailable"}
370
372
  try:
371
- await self.consent.obtain_key(browser_for(browser), reason=f"release {endpoint}")
373
+ await self.consent.obtain_key(browser_for(browser), reason=f"sync them to {endpoint}")
372
374
  except ConsentError:
373
375
  return {"status": "denied"}
374
376
  return {"status": "approved", "nonce": nonce, "endpoint": endpoint}
@@ -50,7 +50,9 @@ async def developer_id_signed(app_path: Path) -> bool:
50
50
  returns ``False``.
51
51
  """
52
52
  result = await anyio.run_process(
53
- [CODESIGN, "--verify", "--strict", "-R", DEVELOPER_ID_REQUIREMENT, str(app_path)],
53
+ # -R takes the requirement inline via `-R=<req>`; as a separate argv (`-R`, `<req>`)
54
+ # codesign reads it as a requirement *file* path and fails "invalid requirement".
55
+ [CODESIGN, "--verify", "--strict", f"-R={DEVELOPER_ID_REQUIREMENT}", str(app_path)],
54
56
  check=False,
55
57
  )
56
58
  return result.returncode == 0
@@ -76,7 +78,11 @@ async def supports_contract() -> bool:
76
78
  [str(paths.helper_binary()), "vault-status", PROBE_VAULT],
77
79
  check=False,
78
80
  )
79
- return result.returncode == 0 and CONTRACT_LINE.search(result.stdout) is not None
81
+ # vault-status prints the contract line then exits 2 ("unavailable") for a vault that
82
+ # doesn't exist — our probe vault never does — so the contract line on stdout, not the
83
+ # exit code, is the capability signal. A helper lacking the subcommand prints usage to
84
+ # stderr and emits no such line.
85
+ return CONTRACT_LINE.search(result.stdout) is not None
80
86
 
81
87
 
82
88
  async def install_helper() -> Path:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cookiesync-cli"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "Sync your browser cookies across machines."
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
File without changes