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.
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/PKG-INFO +29 -38
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/README.md +28 -37
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cli.py +9 -4
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/consent.py +3 -3
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/engine.py +12 -2
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/server.py +7 -5
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/helper.py +8 -2
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/pyproject.toml +1 -1
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/LICENSE +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/__init__.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/__main__.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/__init__.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/backend.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/browsers.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/crypto.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/domains.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/getcookie.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/merge.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/models.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/pipeline.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/serialize.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/cookie/stores.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/__init__.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/backend_ssh.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/cache.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/rpc.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/session.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/sync.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/daemon/wire.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/paths.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/py.typed +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/registry.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/service.py +0 -0
- {cookiesync_cli-0.1.1 → cookiesync_cli-0.1.3}/cookiesync/state.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/cookiesync-cli/)
|
|
39
39
|
[](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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
>
|
|
51
|
-
>
|
|
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
|
-
|
|
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
|
|
74
|
-
cookiesync install
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
cookiesync
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
100
|
+
## How it works
|
|
108
101
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
[](https://pypi.org/project/cookiesync-cli/)
|
|
7
7
|
[](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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
>
|
|
19
|
-
>
|
|
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
|
-
|
|
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
|
|
42
|
-
cookiesync install
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
cookiesync
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
68
|
+
## How it works
|
|
76
69
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
52
|
+
the Touch ID dialog.
|
|
53
53
|
"""
|
|
54
|
-
return f"
|
|
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
|
-
|
|
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
|
|
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(
|
|
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=
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|