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.
- bachs_cli-0.1.0/.gitignore +244 -0
- bachs_cli-0.1.0/PKG-INFO +97 -0
- bachs_cli-0.1.0/README.md +71 -0
- bachs_cli-0.1.0/bachs_cli/__init__.py +3 -0
- bachs_cli-0.1.0/bachs_cli/config.py +102 -0
- bachs_cli-0.1.0/bachs_cli/listen.py +266 -0
- bachs_cli-0.1.0/bachs_cli/main.py +252 -0
- bachs_cli-0.1.0/pyproject.toml +52 -0
|
@@ -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/
|
bachs_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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,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"]
|