bachs-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.
@@ -0,0 +1,244 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ *.pyc
7
+ scripts/seed_admin.py
8
+ .DS_Store
9
+
10
+ .env
11
+ .env.stripe-smoke
12
+
13
+ # Node.js
14
+ node_modules/
15
+ npm-debug.log*
16
+ yarn-debug.log*
17
+ yarn-error.log*
18
+
19
+ misc.py
20
+ # Distribution / packaging
21
+ .Python
22
+ env/
23
+ venv/
24
+ ENV/
25
+ env.bak/
26
+ venv.bak/
27
+ dist/
28
+ build/
29
+ *.egg-info/
30
+ # C extensions
31
+ *.so
32
+ *.dump*
33
+ # Distribution / packaging
34
+ .Python
35
+ build/
36
+ develop-eggs/
37
+ dist/
38
+ downloads/
39
+ eggs/
40
+ .eggs/
41
+ lib64/
42
+ parts/
43
+ sdist/
44
+ var/
45
+ wheels/
46
+ share/python-wheels/
47
+ *.egg-info/
48
+ .installed.cfg
49
+ *.egg
50
+ MANIFEST
51
+
52
+ # PyInstaller
53
+ # Usually these files are written by a python script from a template
54
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
55
+ *.manifest
56
+ *.spec
57
+
58
+ # Installer logs
59
+ pip-log.txt
60
+ pip-delete-this-directory.txt
61
+
62
+ # Pytest
63
+ .cache
64
+ .pytest_cache/
65
+
66
+ # mypy
67
+ .mypy_cache/
68
+ .dmypy.json
69
+ dmypy.json
70
+
71
+ # Jupyter Notebook checkpoints
72
+ .ipynb_checkpoints
73
+
74
+ # VS Code settings
75
+ .vscode/
76
+
77
+ # PyCharm
78
+ .idea/
79
+ *.iml
80
+
81
+ # MacOS
82
+ .DS_Store
83
+
84
+ # Windows
85
+ Thumbs.db
86
+ ehthumbs.db
87
+ Icon?
88
+ Desktop.ini
89
+
90
+ # Coverage reports
91
+ htmlcov/
92
+ # Unit test / coverage reports
93
+ htmlcov/
94
+ .tox/
95
+ .nox/
96
+ .coverage
97
+ .coverage.*
98
+ .cache
99
+ nosetests.xml
100
+ coverage.xml
101
+ *.cover
102
+
103
+ # Environment variables
104
+ .env
105
+
106
+ # Pyre type checker
107
+ .pyre/
108
+ *.py,cover
109
+ .hypothesis/
110
+ .pytest_cache/
111
+ cover/
112
+
113
+ # Translations
114
+ *.mo
115
+ *.pot
116
+
117
+ # Django stuff:
118
+ *.log
119
+ local_settings.py
120
+ db.sqlite3
121
+ db.sqlite3-journal
122
+
123
+ # Flask stuff:
124
+ instance/
125
+ .webassets-cache
126
+
127
+ # Scrapy stuff:
128
+ .scrapy
129
+
130
+ # Sphinx documentation
131
+ docs/_build/
132
+
133
+ # Other files to ignore (customize as needed)
134
+ *.log
135
+ *.pot
136
+ *.pyc
137
+
138
+ # Ignore local configuration files
139
+ local_settings.py
140
+ # PyBuilder
141
+ .pybuilder/
142
+ target/
143
+
144
+ # Jupyter Notebook
145
+ .ipynb_checkpoints
146
+
147
+ # IPython
148
+ profile_default/
149
+ ipython_config.py
150
+
151
+ # pyenv
152
+ # For a library or package, you might want to ignore these files since the code is
153
+ # intended to run in multiple environments; otherwise, check them in:
154
+ # .python-version
155
+
156
+ # pipenv
157
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
158
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
159
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
160
+ # install all needed dependencies.
161
+ #Pipfile.lock
162
+
163
+ # poetry
164
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
165
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
166
+ # commonly ignored for libraries.
167
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
168
+ #poetry.lock
169
+
170
+ # pdm
171
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
172
+ #pdm.lock
173
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
174
+ # in version control.
175
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
176
+ .pdm.toml
177
+ .pdm-python
178
+ .pdm-build/
179
+
180
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
181
+ __pypackages__/
182
+
183
+ # Celery stuff
184
+ celerybeat-schedule
185
+ celerybeat.pid
186
+
187
+ # SageMath parsed files
188
+ *.sage.py
189
+
190
+ # Environments
191
+ .env
192
+ .venv/
193
+ env/
194
+ venv/
195
+ ENV/
196
+ env.bak/
197
+ venv.bak/
198
+
199
+ # Spyder project settings
200
+ .spyderproject
201
+ .spyproject
202
+
203
+ # Rope project settings
204
+ .ropeproject
205
+
206
+ # mkdocs documentation
207
+ /site
208
+
209
+ # mypy
210
+ .mypy_cache/
211
+ .dmypy.json
212
+ dmypy.json
213
+
214
+ # Pyre type checker
215
+ .pyre/
216
+
217
+ # pytype static type analyzer
218
+ .pytype/
219
+
220
+ # Cython debug symbols
221
+ cython_debug/
222
+
223
+ # PyCharm
224
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
225
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
226
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
227
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
228
+ #.idea/
229
+ node_modules/
230
+ */node_modules/
231
+ .aider*
232
+
233
+ # Simulator load-test runtime manifest
234
+ scripts/loadtest/manifest.json
235
+
236
+ # Simulator load-test input data (checkout id CSVs) — keep local, not in repo
237
+ test_webhooks_*.csv
238
+
239
+ # SDD agent scratch (briefs, reports, ledgers) — never a deliverable.
240
+ .superpowers/
241
+
242
+ # Reconciliation report output — contains customer names
243
+ payaza_recon*.csv
244
+ .worktrees/
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: bachs-cli
3
+ Version: 0.1.0
4
+ Summary: Forward live Bachs webhook events to your local machine
5
+ Project-URL: Homepage, https://bachs.io
6
+ Project-URL: Documentation, https://docs.bachs.io/developer-portal/local-testing
7
+ Project-URL: Changelog, https://docs.bachs.io/changelog/api
8
+ Author-email: Bachs <support@bachs.io>
9
+ License: MIT
10
+ Keywords: bachs,cli,local-testing,payments,webhooks
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: websockets>=12.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # bachs CLI
28
+
29
+ Forward live webhook events to your local machine. No tunnel, no public URL, no
30
+ third-party account.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install bachs-cli
36
+ # or, without installing:
37
+ pipx run bachs-cli listen --forward-to localhost:3000/webhooks
38
+ ```
39
+
40
+ ## Use
41
+
42
+ ```bash
43
+ bachs login --api-key sk_sandbox_...
44
+ bachs listen --forward-to localhost:3000/webhooks
45
+ ```
46
+
47
+ Every delivery prints as it arrives — event type, the status your handler
48
+ returned, and how long it took:
49
+
50
+ ```
51
+ Ready! Forwarding sandbox events to http://localhost:3000/webhooks
52
+ Your webhook signing secret is whsec_a1b2c3... (^C to quit)
53
+ Session whls_8f2e… · 26 event type(s)
54
+
55
+ ✓ collection.succeeded evt_3ab4e0d5 200 [42ms]
56
+ ✗ refund.paid evt_7c1f22a9 500 [131ms]
57
+ ```
58
+
59
+ Narrow it down:
60
+
61
+ ```bash
62
+ bachs listen --forward-to localhost:3000/webhooks --events collection.succeeded,refund.paid
63
+ ```
64
+
65
+ Redeliver something that already happened. This needs no session and no socket
66
+ — it works against your registered endpoints too:
67
+
68
+ ```bash
69
+ bachs events replay evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1
70
+ ```
71
+
72
+ ## How it works
73
+
74
+ The connection is opened **outbound** from your machine, so nothing needs to
75
+ reach you: no public URL, no inbound port, and it works behind NAT and
76
+ corporate firewalls.
77
+
78
+ Each session gets its **own signing secret**, printed when it starts. Payloads
79
+ are genuinely signed with it, so your verification code is exercised for real —
80
+ and your production endpoint's secret never leaves production.
81
+
82
+ A session is a webhook destination like any other, so event filtering,
83
+ delivery records and the dashboard all work the same way.
84
+
85
+ ## Environments
86
+
87
+ The API key prefix picks the environment: `sk_sandbox_` for sandbox,
88
+ `sk_live_` for production. There is no `--env` flag to get out of step with the
89
+ key you are using.
90
+
91
+ ## Notes
92
+
93
+ - Deliveries to a session are **not retried**. If your handler is down or your
94
+ laptop is asleep, the event is dropped and shown as failed — you are watching
95
+ the terminal, and a backlog arriving tomorrow would not help.
96
+ - Credentials are stored at `~/.config/bachs/config.json`, mode 0600. Set
97
+ `BACHS_API_KEY` instead if you would rather not write a key to disk.
@@ -0,0 +1,71 @@
1
+ # bachs CLI
2
+
3
+ Forward live webhook events to your local machine. No tunnel, no public URL, no
4
+ third-party account.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install bachs-cli
10
+ # or, without installing:
11
+ pipx run bachs-cli listen --forward-to localhost:3000/webhooks
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```bash
17
+ bachs login --api-key sk_sandbox_...
18
+ bachs listen --forward-to localhost:3000/webhooks
19
+ ```
20
+
21
+ Every delivery prints as it arrives — event type, the status your handler
22
+ returned, and how long it took:
23
+
24
+ ```
25
+ Ready! Forwarding sandbox events to http://localhost:3000/webhooks
26
+ Your webhook signing secret is whsec_a1b2c3... (^C to quit)
27
+ Session whls_8f2e… · 26 event type(s)
28
+
29
+ ✓ collection.succeeded evt_3ab4e0d5 200 [42ms]
30
+ ✗ refund.paid evt_7c1f22a9 500 [131ms]
31
+ ```
32
+
33
+ Narrow it down:
34
+
35
+ ```bash
36
+ bachs listen --forward-to localhost:3000/webhooks --events collection.succeeded,refund.paid
37
+ ```
38
+
39
+ Redeliver something that already happened. This needs no session and no socket
40
+ — it works against your registered endpoints too:
41
+
42
+ ```bash
43
+ bachs events replay evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1
44
+ ```
45
+
46
+ ## How it works
47
+
48
+ The connection is opened **outbound** from your machine, so nothing needs to
49
+ reach you: no public URL, no inbound port, and it works behind NAT and
50
+ corporate firewalls.
51
+
52
+ Each session gets its **own signing secret**, printed when it starts. Payloads
53
+ are genuinely signed with it, so your verification code is exercised for real —
54
+ and your production endpoint's secret never leaves production.
55
+
56
+ A session is a webhook destination like any other, so event filtering,
57
+ delivery records and the dashboard all work the same way.
58
+
59
+ ## Environments
60
+
61
+ The API key prefix picks the environment: `sk_sandbox_` for sandbox,
62
+ `sk_live_` for production. There is no `--env` flag to get out of step with the
63
+ key you are using.
64
+
65
+ ## Notes
66
+
67
+ - Deliveries to a session are **not retried**. If your handler is down or your
68
+ laptop is asleep, the event is dropped and shown as failed — you are watching
69
+ the terminal, and a backlog arriving tomorrow would not help.
70
+ - Credentials are stored at `~/.config/bachs/config.json`, mode 0600. Set
71
+ `BACHS_API_KEY` instead if you would rather not write a key to disk.
@@ -0,0 +1,3 @@
1
+ """The `bachs` CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,102 @@
1
+ """
2
+ Credentials and environment for the CLI.
3
+
4
+ The API key prefix already encodes the environment — `sk_sandbox_` against
5
+ sandbox, `sk_live_` against production — so there is no separate `--env` flag
6
+ to get out of step with the key being used. Passing a sandbox key and a
7
+ production base URL is not a combination worth supporting.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import stat
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ SANDBOX_BASE_URL = "https://sandbox-api.bachs.io"
20
+ LIVE_BASE_URL = "https://api.bachs.io"
21
+
22
+ CONFIG_DIR = Path(os.environ.get("BACHS_CONFIG_DIR", Path.home() / ".config" / "bachs"))
23
+ CONFIG_PATH = CONFIG_DIR / "config.json"
24
+
25
+
26
+ class ConfigError(Exception):
27
+ """Something is wrong with the stored or supplied credentials."""
28
+
29
+
30
+ @dataclass
31
+ class Config:
32
+ api_key: str
33
+ base_url: str
34
+
35
+ @property
36
+ def is_sandbox(self) -> bool:
37
+ return self.api_key.startswith("sk_sandbox_")
38
+
39
+ @property
40
+ def ws_url(self) -> str:
41
+ """The socket URL for this environment."""
42
+ return self.base_url.replace("https://", "wss://").replace(
43
+ "http://", "ws://"
44
+ ) + "/v1/webhooks/listen/connect"
45
+
46
+
47
+ def _base_url_for(api_key: str) -> str:
48
+ if api_key.startswith("sk_sandbox_"):
49
+ return SANDBOX_BASE_URL
50
+ if api_key.startswith("sk_live_"):
51
+ return LIVE_BASE_URL
52
+ raise ConfigError(
53
+ "API key must start with sk_sandbox_ or sk_live_. "
54
+ "The prefix selects the environment."
55
+ )
56
+
57
+
58
+ def save(api_key: str) -> Path:
59
+ """
60
+ Store the key for later commands.
61
+
62
+ Written 0600 in a 0700 directory. This is a plaintext secret on disk, which
63
+ is worth being explicit about: the OS keychain would be better, and the
64
+ cross-platform story for that is enough work to be its own change. Until
65
+ then the file permissions are the whole protection, so they are set
66
+ deliberately rather than left to the umask.
67
+ """
68
+ _base_url_for(api_key) # reject a bad prefix before writing it
69
+
70
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
71
+ os.chmod(CONFIG_DIR, stat.S_IRWXU)
72
+
73
+ CONFIG_PATH.write_text(json.dumps({"api_key": api_key}, indent=2) + "\n")
74
+ os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
75
+ return CONFIG_PATH
76
+
77
+
78
+ def load(api_key_override: Optional[str] = None) -> Config:
79
+ """
80
+ Resolve credentials: --api-key, then BACHS_API_KEY, then the config file.
81
+
82
+ The env var sits above the file so CI can run without writing one, and
83
+ below the flag so a one-off invocation can target another account.
84
+ """
85
+ api_key = api_key_override or os.environ.get("BACHS_API_KEY")
86
+
87
+ if not api_key:
88
+ if not CONFIG_PATH.exists():
89
+ raise ConfigError(
90
+ "No API key found. Run `bachs login --api-key sk_sandbox_...`, "
91
+ "set BACHS_API_KEY, or pass --api-key."
92
+ )
93
+ try:
94
+ api_key = json.loads(CONFIG_PATH.read_text()).get("api_key")
95
+ except (json.JSONDecodeError, OSError) as exc:
96
+ raise ConfigError(f"Could not read {CONFIG_PATH}: {exc}") from exc
97
+
98
+ if not api_key:
99
+ raise ConfigError(f"No api_key in {CONFIG_PATH}.")
100
+
101
+ base_url = os.environ.get("BACHS_BASE_URL") or _base_url_for(api_key)
102
+ return Config(api_key=api_key, base_url=base_url)
@@ -0,0 +1,266 @@
1
+ """
2
+ `bachs listen` — forward live events to a local port.
3
+
4
+ The connection is opened OUTBOUND from this machine. That is what removes the
5
+ need for a tunnel: no public URL, no inbound port, and it works behind NAT and
6
+ corporate firewalls because it is an ordinary outgoing HTTPS connection.
7
+
8
+ Shape of the loop: create a session over HTTP, dial the socket, and for each
9
+ frame that arrives POST it to the local target and report the result back up
10
+ the socket. Reconnect with backoff when the socket drops, because laptops
11
+ sleep, wifi flaps, and the server cycles connections on deploy.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import time
19
+ from typing import Optional
20
+
21
+ import httpx
22
+ import websockets
23
+
24
+ from bachs_cli.config import Config
25
+
26
+ # The server tells us how often to cycle; this is the fallback if it does not.
27
+ DEFAULT_RECONNECT_SECONDS = 60
28
+
29
+ # Reconnect backoff. Starts fast because most drops are transient (a deploy, a
30
+ # brief network blip) and a developer watching a terminal should see it come
31
+ # back immediately. Caps low enough that a laptop waking from sleep reconnects
32
+ # within a few seconds rather than sitting idle.
33
+ BACKOFF_INITIAL = 1.0
34
+ BACKOFF_MAX = 15.0
35
+ BACKOFF_FACTOR = 2.0
36
+
37
+
38
+ class Colors:
39
+ GREEN = "\033[32m"
40
+ RED = "\033[31m"
41
+ YELLOW = "\033[33m"
42
+ DIM = "\033[2m"
43
+ BOLD = "\033[1m"
44
+ RESET = "\033[0m"
45
+
46
+
47
+ def _status_colour(status: Optional[int]) -> str:
48
+ if status is None:
49
+ return Colors.RED
50
+ if 200 <= status < 300:
51
+ return Colors.GREEN
52
+ return Colors.RED
53
+
54
+
55
+ def _normalise_target(forward_to: str) -> str:
56
+ """
57
+ Accept the shapes people actually type.
58
+
59
+ `localhost:3000/webhooks`, `:3000/webhooks` and a full URL should all work
60
+ — requiring a scheme for a local address is pointless friction.
61
+ """
62
+ target = forward_to.strip()
63
+ if target.startswith(("http://", "https://")):
64
+ return target
65
+ if target.startswith(":"):
66
+ return f"http://localhost{target}"
67
+ return f"http://{target}"
68
+
69
+
70
+ async def _create_session(
71
+ config: Config,
72
+ *,
73
+ forward_to: str,
74
+ events: Optional[list[str]],
75
+ device_name: Optional[str],
76
+ ) -> dict:
77
+ async with httpx.AsyncClient(timeout=30) as client:
78
+ response = await client.post(
79
+ f"{config.base_url}/v1/webhooks/listen/sessions",
80
+ headers={"Authorization": f"Bearer {config.api_key}"},
81
+ json={
82
+ "device_name": device_name,
83
+ "forward_to": forward_to,
84
+ "events": events,
85
+ },
86
+ )
87
+
88
+ if response.status_code >= 400:
89
+ # Surface the server's message rather than a bare status: a bad event
90
+ # name or a key missing webhooks:write is worth reading.
91
+ try:
92
+ detail = response.json()
93
+ except Exception:
94
+ detail = response.text
95
+ raise RuntimeError(f"Could not create session ({response.status_code}): {detail}")
96
+
97
+ return response.json()
98
+
99
+
100
+ async def _forward_one(
101
+ client: httpx.AsyncClient, target: str, frame: dict
102
+ ) -> tuple[Optional[int], float, Optional[str]]:
103
+ """
104
+ POST one event to the local handler.
105
+
106
+ Returns (status, duration_ms, error). The signature header is passed
107
+ through exactly as the server built it, so the developer's verification
108
+ code sees a real signed request.
109
+ """
110
+ headers = {
111
+ "Content-Type": "application/json",
112
+ "X-Bachs-Timestamp": str(frame.get("timestamp", "")),
113
+ "User-Agent": "Bachs-CLI-Listen/1.0",
114
+ }
115
+ signature = frame.get("signature")
116
+ if signature:
117
+ headers["X-Bachs-Signature-V2"] = signature
118
+
119
+ started = time.monotonic()
120
+ try:
121
+ response = await client.post(
122
+ target, content=frame.get("payload", ""), headers=headers
123
+ )
124
+ return response.status_code, (time.monotonic() - started) * 1000, None
125
+ except Exception as exc:
126
+ return None, (time.monotonic() - started) * 1000, str(exc)
127
+
128
+
129
+ async def _run_socket(
130
+ config: Config, session: dict, target: str, *, cycle_after: float
131
+ ) -> None:
132
+ """
133
+ One connection's lifetime.
134
+
135
+ Returns normally when it is time to cycle the connection, which the caller
136
+ treats as an expected reconnect rather than a failure.
137
+ """
138
+ ws_url = f"{config.ws_url}?token={session['token']}"
139
+ deadline = time.monotonic() + cycle_after
140
+
141
+ async with websockets.connect(ws_url, open_timeout=20) as socket:
142
+ async with httpx.AsyncClient(timeout=30, follow_redirects=False) as http:
143
+ while True:
144
+ remaining = deadline - time.monotonic()
145
+ if remaining <= 0:
146
+ return
147
+
148
+ try:
149
+ raw = await asyncio.wait_for(socket.recv(), timeout=remaining)
150
+ except asyncio.TimeoutError:
151
+ return # time to cycle
152
+
153
+ try:
154
+ frame = json.loads(raw)
155
+ except json.JSONDecodeError:
156
+ continue
157
+
158
+ kind = frame.get("type")
159
+ if kind == "ready":
160
+ continue
161
+ if kind == "pong":
162
+ continue
163
+ if kind != "webhook.forward":
164
+ continue
165
+
166
+ status, duration_ms, error = await _forward_one(http, target, frame)
167
+
168
+ colour = _status_colour(status)
169
+ event_type = frame.get("event_type", "?")
170
+ event_id = frame.get("event_id", "?")
171
+ if error:
172
+ print(
173
+ f"{colour}✗{Colors.RESET} {event_type} "
174
+ f"{Colors.DIM}{event_id}{Colors.RESET} "
175
+ f"{colour}{error}{Colors.RESET} "
176
+ f"{Colors.DIM}[{duration_ms:.0f}ms]{Colors.RESET}",
177
+ flush=True,
178
+ )
179
+ else:
180
+ mark = "✓" if status and 200 <= status < 300 else "✗"
181
+ print(
182
+ f"{colour}{mark}{Colors.RESET} {event_type} "
183
+ f"{Colors.DIM}{event_id}{Colors.RESET} "
184
+ f"{colour}{status}{Colors.RESET} "
185
+ f"{Colors.DIM}[{duration_ms:.0f}ms]{Colors.RESET}",
186
+ flush=True,
187
+ )
188
+
189
+ # Report back so the delivery shows up in the dashboard with
190
+ # what the local handler actually said.
191
+ try:
192
+ await socket.send(
193
+ json.dumps(
194
+ {
195
+ "type": "delivery_result",
196
+ "event_id": event_id,
197
+ "attempt_id": frame.get("attempt_id"),
198
+ "status": status,
199
+ "duration_ms": round(duration_ms, 2),
200
+ "error": error,
201
+ }
202
+ )
203
+ )
204
+ except Exception:
205
+ # The socket is going away; the outer loop will reconnect.
206
+ return
207
+
208
+
209
+ async def listen(
210
+ config: Config,
211
+ *,
212
+ forward_to: str,
213
+ events: Optional[list[str]] = None,
214
+ device_name: Optional[str] = None,
215
+ ) -> int:
216
+ target = _normalise_target(forward_to)
217
+
218
+ session = await _create_session(
219
+ config, forward_to=forward_to, events=events, device_name=device_name
220
+ )
221
+ cycle_after = float(session.get("reconnect_after_seconds") or DEFAULT_RECONNECT_SECONDS)
222
+
223
+ env = "sandbox" if config.is_sandbox else f"{Colors.YELLOW}live{Colors.RESET}"
224
+ print(f"{Colors.BOLD}Ready!{Colors.RESET} Forwarding {env} events to {target}")
225
+ print(
226
+ f"Your webhook signing secret is "
227
+ f"{Colors.BOLD}{session['signing_secret']}{Colors.RESET} (^C to quit)"
228
+ )
229
+ subscribed = session.get("events") or []
230
+ print(
231
+ f"{Colors.DIM}Session {session['session_id']} · "
232
+ f"{len(subscribed)} event type(s){Colors.RESET}\n",
233
+ flush=True,
234
+ )
235
+
236
+ backoff = BACKOFF_INITIAL
237
+ try:
238
+ while True:
239
+ try:
240
+ await _run_socket(config, session, target, cycle_after=cycle_after)
241
+ backoff = BACKOFF_INITIAL # a clean cycle is not a failure
242
+ except (OSError, websockets.WebSocketException) as exc:
243
+ print(
244
+ f"{Colors.YELLOW}⟳{Colors.RESET} {Colors.DIM}connection lost "
245
+ f"({exc}); reconnecting in {backoff:.0f}s{Colors.RESET}",
246
+ flush=True,
247
+ )
248
+ await asyncio.sleep(backoff)
249
+ backoff = min(backoff * BACKOFF_FACTOR, BACKOFF_MAX)
250
+ except asyncio.CancelledError:
251
+ raise
252
+ finally:
253
+ # Release the session so fanout stops aiming at a socket nobody holds.
254
+ # Best effort: the server's reaper covers the case where this does not
255
+ # get to run.
256
+ try:
257
+ async with httpx.AsyncClient(timeout=10) as client:
258
+ await client.delete(
259
+ f"{config.base_url}/v1/webhooks/listen/sessions/"
260
+ f"{session['session_id']}",
261
+ headers={"Authorization": f"Bearer {config.api_key}"},
262
+ )
263
+ except Exception:
264
+ pass
265
+
266
+ return 0
@@ -0,0 +1,252 @@
1
+ """
2
+ `bachs` command-line interface.
3
+
4
+ Command tree:
5
+
6
+ bachs login --api-key sk_sandbox_...
7
+ bachs whoami
8
+ bachs listen --forward-to localhost:3000/webhooks [--events a,b] [--all]
9
+ bachs events replay <evt_id> [--forward-to ...]
10
+
11
+ A note on flag naming, because this is the point where it is cheap to get
12
+ right. `--events` takes event type names and is the only way to select them;
13
+ there is deliberately no parallel vocabulary for a second kind of event. Stripe
14
+ added one (`--thin-events`, `--forward-thin-to`, `--forward-thin-connect-to`)
15
+ and spent years unwinding it. If Bachs ever ships a second payload shape, it
16
+ extends `--events`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import asyncio
23
+ import sys
24
+ from typing import Optional
25
+
26
+ import httpx
27
+
28
+ from bachs_cli import __version__
29
+ from bachs_cli.config import ConfigError, load, save
30
+ from bachs_cli.listen import Colors, listen
31
+
32
+
33
+ _SUBCOMMANDS = {"login", "whoami", "listen", "events"}
34
+
35
+
36
+ def _leading_api_key(argv: list[str]) -> Optional[str]:
37
+ """
38
+ Pull --api-key from the part of argv before the subcommand.
39
+
40
+ Scans only up to the first subcommand so `bachs events replay --api-key K`
41
+ is left to argparse rather than being read twice.
42
+ """
43
+ for index, token in enumerate(argv):
44
+ if token in _SUBCOMMANDS:
45
+ return None
46
+ if token == "--api-key" and index + 1 < len(argv):
47
+ return argv[index + 1]
48
+ if token.startswith("--api-key="):
49
+ return token.split("=", 1)[1]
50
+ return None
51
+
52
+
53
+ def _split_events(value: Optional[str]) -> Optional[list[str]]:
54
+ if not value:
55
+ return None
56
+ items = [item.strip() for item in value.split(",") if item.strip()]
57
+ return items or None
58
+
59
+
60
+ def _cmd_login(args: argparse.Namespace) -> int:
61
+ path = save(args.api_key)
62
+ env = "sandbox" if args.api_key.startswith("sk_sandbox_") else "live"
63
+ print(f"Saved {env} credentials to {path}")
64
+ if env == "live":
65
+ print(
66
+ f"{Colors.YELLOW}Note:{Colors.RESET} this is a live key. "
67
+ f"`bachs listen` will forward real events."
68
+ )
69
+ return 0
70
+
71
+
72
+ def _cmd_whoami(args: argparse.Namespace) -> int:
73
+ config = load(args.api_key)
74
+ env = "sandbox" if config.is_sandbox else "live"
75
+ print(f"Environment: {env}")
76
+ print(f"API base: {config.base_url}")
77
+ print(f"Key: {config.api_key[:16]}…")
78
+ return 0
79
+
80
+
81
+ def _cmd_listen(args: argparse.Namespace) -> int:
82
+ config = load(args.api_key)
83
+
84
+ if args.events and args.all:
85
+ # Silently preferring one would leave the developer wondering why a
86
+ # filter they passed did nothing.
87
+ print(
88
+ f"{Colors.RED}error:{Colors.RESET} pass either --events or --all, "
89
+ f"not both.",
90
+ file=sys.stderr,
91
+ )
92
+ return 2
93
+
94
+ events = None if args.all else _split_events(args.events)
95
+
96
+ try:
97
+ return asyncio.run(
98
+ listen(
99
+ config,
100
+ forward_to=args.forward_to,
101
+ events=events,
102
+ device_name=args.device_name,
103
+ )
104
+ )
105
+ except KeyboardInterrupt:
106
+ print("\nStopped.")
107
+ return 130
108
+
109
+
110
+ def _cmd_events_replay(args: argparse.Namespace) -> int:
111
+ """
112
+ Redeliver a past event.
113
+
114
+ This is a plain API call — no socket and no session involved. It works
115
+ against registered endpoints too, which is why it is useful on its own and
116
+ not only alongside `listen`.
117
+ """
118
+ config = load(args.api_key)
119
+
120
+ response = httpx.post(
121
+ f"{config.base_url}/v1/webhooks/replay",
122
+ headers={"Authorization": f"Bearer {config.api_key}"},
123
+ json={"event_id": args.event_id},
124
+ timeout=30,
125
+ )
126
+
127
+ if response.status_code >= 400:
128
+ try:
129
+ detail = response.json()
130
+ except Exception:
131
+ detail = response.text
132
+ print(
133
+ f"{Colors.RED}error:{Colors.RESET} replay failed "
134
+ f"({response.status_code}): {detail}",
135
+ file=sys.stderr,
136
+ )
137
+ return 1
138
+
139
+ body = response.json()
140
+ print(
141
+ f"{Colors.GREEN}✓{Colors.RESET} queued {body.get('event_type')} "
142
+ f"{Colors.DIM}{body.get('event_id')}{Colors.RESET} "
143
+ f"attempt {body.get('attempt_id')}"
144
+ )
145
+ return 0
146
+
147
+
148
+ def build_parser() -> argparse.ArgumentParser:
149
+ parser = argparse.ArgumentParser(
150
+ prog="bachs", description="Bachs command-line interface"
151
+ )
152
+ parser.add_argument("--version", action="version", version=f"bachs {__version__}")
153
+ parser.add_argument(
154
+ "--api-key",
155
+ default=None,
156
+ help="Override the stored key. The prefix selects the environment.",
157
+ )
158
+
159
+ # --api-key is accepted both before and after the subcommand, because both
160
+ # read naturally and guessing wrong is a confusing "unrecognized arguments"
161
+ # error. A shared parent parser keeps one definition for both positions.
162
+ key_parent = argparse.ArgumentParser(add_help=False)
163
+ key_parent.add_argument(
164
+ "--api-key",
165
+ default=None,
166
+ dest="api_key",
167
+ help="Override the stored key. The prefix selects the environment.",
168
+ )
169
+
170
+ sub = parser.add_subparsers(dest="command", required=True)
171
+
172
+ p_login = sub.add_parser("login", help="Store an API key")
173
+ p_login.add_argument("--api-key", required=True, dest="api_key")
174
+ p_login.set_defaults(func=_cmd_login)
175
+
176
+ p_whoami = sub.add_parser(
177
+ "whoami", parents=[key_parent], help="Show the active environment"
178
+ )
179
+ p_whoami.set_defaults(func=_cmd_whoami)
180
+
181
+ p_listen = sub.add_parser(
182
+ "listen",
183
+ parents=[key_parent],
184
+ help="Forward live events to a local port",
185
+ )
186
+ p_listen.add_argument(
187
+ "--forward-to",
188
+ "-f",
189
+ required=True,
190
+ help="Local target, e.g. localhost:3000/webhooks",
191
+ )
192
+ p_listen.add_argument(
193
+ "--events",
194
+ "-e",
195
+ default=None,
196
+ help="Comma-separated event types, e.g. collection.succeeded,refund.paid",
197
+ )
198
+ p_listen.add_argument(
199
+ "--all",
200
+ action="store_true",
201
+ help="Forward every event type. The default when --events is omitted.",
202
+ )
203
+ p_listen.add_argument(
204
+ "--device-name",
205
+ default=None,
206
+ help="Label for this session in the dashboard",
207
+ )
208
+ p_listen.set_defaults(func=_cmd_listen)
209
+
210
+ p_events = sub.add_parser("events", help="Work with past events")
211
+ events_sub = p_events.add_subparsers(dest="events_command", required=True)
212
+ p_replay = events_sub.add_parser(
213
+ "replay", parents=[key_parent], help="Redeliver a past event"
214
+ )
215
+ p_replay.add_argument("event_id", help="e.g. evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1")
216
+ p_replay.add_argument(
217
+ "--forward-to",
218
+ default=None,
219
+ help="Accepted for symmetry with `listen`; replay targets your "
220
+ "registered endpoints and any live session.",
221
+ )
222
+ p_replay.set_defaults(func=_cmd_events_replay)
223
+
224
+ return parser
225
+
226
+
227
+ def main(argv: Optional[list[str]] = None) -> int:
228
+ parser = build_parser()
229
+
230
+ # --api-key is defined on the top-level parser AND on the subcommands, so
231
+ # `bachs --api-key K whoami` and `bachs whoami --api-key K` both parse. The
232
+ # subcommand's parser writes to the same attribute though, so a subcommand
233
+ # with no value overwrites a leading one with None. Read the leading form
234
+ # off the raw argv first, and fall back to it.
235
+ leading_key = _leading_api_key(argv if argv is not None else sys.argv[1:])
236
+
237
+ args = parser.parse_args(argv)
238
+ if getattr(args, "api_key", None) is None and leading_key:
239
+ args.api_key = leading_key
240
+
241
+ try:
242
+ return args.func(args)
243
+ except ConfigError as exc:
244
+ print(f"{Colors.RED}error:{Colors.RESET} {exc}", file=sys.stderr)
245
+ return 2
246
+ except RuntimeError as exc:
247
+ print(f"{Colors.RED}error:{Colors.RESET} {exc}", file=sys.stderr)
248
+ return 1
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bachs-cli"
7
+ version = "0.1.0"
8
+ description = "Forward live Bachs webhook events to your local machine"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Bachs", email = "support@bachs.io" }]
13
+ keywords = ["bachs", "webhooks", "payments", "cli", "local-testing"]
14
+
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Testing",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ ]
28
+
29
+ # Kept deliberately small. This installs onto developer machines we do not
30
+ # control, so every dependency is a chance to conflict with something already
31
+ # in their environment. Both of these are pure-python and near-ubiquitous.
32
+ dependencies = [
33
+ "httpx>=0.27",
34
+ "websockets>=12.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://bachs.io"
39
+ Documentation = "https://docs.bachs.io/developer-portal/local-testing"
40
+ Changelog = "https://docs.bachs.io/changelog/api"
41
+
42
+ # `bachs-cli` is the package; `bachs` is the command. That split is the
43
+ # ecosystem convention (twilio/twilio-cli, aws/awscli) and it keeps the bare
44
+ # `bachs` name on PyPI free for the SDK, which is what people expect to import.
45
+ [project.scripts]
46
+ bachs = "bachs_cli.main:main"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["bachs_cli"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["bachs_cli", "README.md"]