cli-anything-brissoto-pay 1.0.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.
Files changed (48) hide show
  1. cli_anything_brissoto_pay-1.0.0/.gitignore +24 -0
  2. cli_anything_brissoto_pay-1.0.0/.python-version +1 -0
  3. cli_anything_brissoto_pay-1.0.0/PKG-INFO +84 -0
  4. cli_anything_brissoto_pay-1.0.0/README.md +73 -0
  5. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/__init__.py +3 -0
  6. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/__main__.py +5 -0
  7. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/brissoto_pay_cli.py +195 -0
  8. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/commands/__init__.py +1 -0
  9. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/commands/apps.py +144 -0
  10. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/commands/go_live.py +75 -0
  11. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/commands/methods.py +58 -0
  12. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/commands/payments.py +120 -0
  13. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/commands/servers.py +164 -0
  14. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/commands/webhooks.py +80 -0
  15. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/__init__.py +0 -0
  16. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/client.py +91 -0
  17. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/config.py +35 -0
  18. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/errors.py +19 -0
  19. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/gateway.py +109 -0
  20. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/login.py +118 -0
  21. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/runtime.py +52 -0
  22. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/core/session.py +62 -0
  23. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/skills/SKILL.md +156 -0
  24. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/__init__.py +0 -0
  25. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/conftest.py +63 -0
  26. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/fakes.py +89 -0
  27. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_apps.py +308 -0
  28. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_client.py +68 -0
  29. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_errors.py +56 -0
  30. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_gateway.py +176 -0
  31. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_go_live.py +177 -0
  32. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_login.py +326 -0
  33. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_logout.py +60 -0
  34. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_main.py +39 -0
  35. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_methods.py +110 -0
  36. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_payments.py +235 -0
  37. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_refresh.py +85 -0
  38. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_repl.py +66 -0
  39. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_servers.py +259 -0
  40. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_skill.py +67 -0
  41. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_webhooks.py +118 -0
  42. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/tests/test_whoami.py +105 -0
  43. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/utils/__init__.py +0 -0
  44. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/utils/options.py +36 -0
  45. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/utils/output.py +40 -0
  46. cli_anything_brissoto_pay-1.0.0/cli_anything/brissoto_pay/utils/repl.py +53 -0
  47. cli_anything_brissoto_pay-1.0.0/pyproject.toml +33 -0
  48. cli_anything_brissoto_pay-1.0.0/uv.lock +722 -0
@@ -0,0 +1,24 @@
1
+ node_modules
2
+ .DS_Store
3
+ dist
4
+ dist-ssr
5
+ *.local
6
+ .env
7
+ .nitro
8
+ .tanstack
9
+ .wrangler
10
+ .output
11
+ .vinxi
12
+ __unconfig*
13
+ todos.json
14
+ /coverage
15
+ /test-results
16
+ /playwright-report
17
+ .env.prod.convex
18
+ .expo
19
+
20
+ # Python CLI (cli/)
21
+ .venv
22
+ __pycache__
23
+ .pytest_cache
24
+ .coverage
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: cli-anything-brissoto-pay
3
+ Version: 1.0.0
4
+ Summary: Set apps up in the Brissoto Pay gateway from a coding agent.
5
+ Project-URL: Documentation, https://pay.brissoto.com/docs
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: click>=8.1
9
+ Requires-Dist: convex>=0.8
10
+ Description-Content-Type: text/markdown
11
+
12
+ # cli-anything-brissoto-pay
13
+
14
+ Sets an app up in the Brissoto Pay gateway, from a coding agent or by hand. Admins only.
15
+
16
+ ## Install
17
+
18
+ Needs [uv](https://docs.astral.sh/uv/) (`brew install uv`). Nothing to install in the project: `uvx` runs the latest release.
19
+
20
+ ## With a coding agent
21
+
22
+ 1. Open [pay.brissoto.com/docs](https://pay.brissoto.com/docs), click **Copy** on "Set up with a coding agent".
23
+ 2. Paste it into your coding agent (Claude Code, Codex, Cursor) in the app's project.
24
+ 3. Approve the sign-in in your browser, then answer its questions.
25
+
26
+ It starts in test and asks you before replacing a key or going live.
27
+
28
+ ## By hand
29
+
30
+ ```sh
31
+ uvx cli-anything-brissoto-pay login # approve in the browser, 30 days
32
+ uvx cli-anything-brissoto-pay apps create --name Heyama --color "#0F766E" --language fr
33
+ uvx cli-anything-brissoto-pay apps get heyama # the Dev server's id
34
+ uvx cli-anything-brissoto-pay servers update <server> --webhook-url https://api.heyama.com/webhooks/pay --return-url http://localhost:3000/paid
35
+ uvx cli-anything-brissoto-pay servers key <server>
36
+ uvx cli-anything-brissoto-pay servers webhook-secret <server>
37
+ uvx cli-anything-brissoto-pay webhooks test <server>
38
+ uvx cli-anything-brissoto-pay go-live heyama --webhook-url https://api.heyama.com/webhooks/pay --return-url https://heyama.com/paid --yes
39
+ ```
40
+
41
+ Put the key and secret in `BRISSOTO_PAY_API_KEY` and `BRISSOTO_PAY_WEBHOOK_SECRET`, in an env file that is not committed.
42
+
43
+ ## Commands
44
+
45
+ | Command | Does |
46
+ | --------------------------------------------------------------- | ---------------------------------------- |
47
+ | `apps list` | Every app |
48
+ | `apps get <slug>` | One app, its servers, URLs and key hints |
49
+ | `apps create --name --color --language [--logo]` | A Test app with a Dev server |
50
+ | `apps branding <slug> [--name] [--color] [--language] [--logo]` | Change its brand |
51
+ | `servers add <slug> <name>` | A test server |
52
+ | `servers update <server> [--webhook-url] [--return-url ...]` | Its URLs |
53
+ | `servers key <server> [--yes]` | Its API key, shown once |
54
+ | `servers webhook-secret <server> [--yes]` | Its webhook secret, shown once |
55
+ | `methods list [--env] [--app]` | Payment methods and their providers |
56
+ | `methods set <slug> <method> --on\|--off` | Offer a method in test, or not |
57
+ | `webhooks test <server> [--wait]` | Send a `webhook.test`, say how it went |
58
+ | `payments list <slug> [--env] [--days] [--status] [--page]` | The app's payments |
59
+ | `payments get <id>` | One payment, its attempts and webhooks |
60
+ | `go-live <slug> [--webhook-url] [--return-url ...] --yes` | Production server, live key and secret |
61
+
62
+ Replacing a key or secret, and `go-live`, fail with `confirmation_required` without `--yes`.
63
+
64
+ No command opens the REPL. Every command takes `--json` and `--help`.
65
+
66
+ | Option | Env var | Default |
67
+ | -------------- | ------------------------- | ------------------------ |
68
+ | `--gateway` | `BRISSOTO_PAY_GATEWAY` | `prod` |
69
+ | `--convex-url` | `BRISSOTO_PAY_CONVEX_URL` | the gateway's Convex URL |
70
+ | `--web-url` | `BRISSOTO_PAY_WEB_URL` | the gateway's dashboard |
71
+
72
+ | Gateway | Convex URL | Dashboard |
73
+ | ------- | -------------------------------------- | -------------------------- |
74
+ | `prod` | `https://api-pay.brissoto.com` | `https://pay.brissoto.com` |
75
+ | `dev` | `https://giant-bison-875.convex.cloud` | `http://localhost:4310` |
76
+
77
+ Sessions: `~/.config/brissoto-pay/sessions.json`, one per Convex URL.
78
+
79
+ ## Develop
80
+
81
+ ```sh
82
+ npm run test:cli
83
+ uv run --directory cli cli-anything-brissoto-pay --help
84
+ ```
@@ -0,0 +1,73 @@
1
+ # cli-anything-brissoto-pay
2
+
3
+ Sets an app up in the Brissoto Pay gateway, from a coding agent or by hand. Admins only.
4
+
5
+ ## Install
6
+
7
+ Needs [uv](https://docs.astral.sh/uv/) (`brew install uv`). Nothing to install in the project: `uvx` runs the latest release.
8
+
9
+ ## With a coding agent
10
+
11
+ 1. Open [pay.brissoto.com/docs](https://pay.brissoto.com/docs), click **Copy** on "Set up with a coding agent".
12
+ 2. Paste it into your coding agent (Claude Code, Codex, Cursor) in the app's project.
13
+ 3. Approve the sign-in in your browser, then answer its questions.
14
+
15
+ It starts in test and asks you before replacing a key or going live.
16
+
17
+ ## By hand
18
+
19
+ ```sh
20
+ uvx cli-anything-brissoto-pay login # approve in the browser, 30 days
21
+ uvx cli-anything-brissoto-pay apps create --name Heyama --color "#0F766E" --language fr
22
+ uvx cli-anything-brissoto-pay apps get heyama # the Dev server's id
23
+ uvx cli-anything-brissoto-pay servers update <server> --webhook-url https://api.heyama.com/webhooks/pay --return-url http://localhost:3000/paid
24
+ uvx cli-anything-brissoto-pay servers key <server>
25
+ uvx cli-anything-brissoto-pay servers webhook-secret <server>
26
+ uvx cli-anything-brissoto-pay webhooks test <server>
27
+ uvx cli-anything-brissoto-pay go-live heyama --webhook-url https://api.heyama.com/webhooks/pay --return-url https://heyama.com/paid --yes
28
+ ```
29
+
30
+ Put the key and secret in `BRISSOTO_PAY_API_KEY` and `BRISSOTO_PAY_WEBHOOK_SECRET`, in an env file that is not committed.
31
+
32
+ ## Commands
33
+
34
+ | Command | Does |
35
+ | --------------------------------------------------------------- | ---------------------------------------- |
36
+ | `apps list` | Every app |
37
+ | `apps get <slug>` | One app, its servers, URLs and key hints |
38
+ | `apps create --name --color --language [--logo]` | A Test app with a Dev server |
39
+ | `apps branding <slug> [--name] [--color] [--language] [--logo]` | Change its brand |
40
+ | `servers add <slug> <name>` | A test server |
41
+ | `servers update <server> [--webhook-url] [--return-url ...]` | Its URLs |
42
+ | `servers key <server> [--yes]` | Its API key, shown once |
43
+ | `servers webhook-secret <server> [--yes]` | Its webhook secret, shown once |
44
+ | `methods list [--env] [--app]` | Payment methods and their providers |
45
+ | `methods set <slug> <method> --on\|--off` | Offer a method in test, or not |
46
+ | `webhooks test <server> [--wait]` | Send a `webhook.test`, say how it went |
47
+ | `payments list <slug> [--env] [--days] [--status] [--page]` | The app's payments |
48
+ | `payments get <id>` | One payment, its attempts and webhooks |
49
+ | `go-live <slug> [--webhook-url] [--return-url ...] --yes` | Production server, live key and secret |
50
+
51
+ Replacing a key or secret, and `go-live`, fail with `confirmation_required` without `--yes`.
52
+
53
+ No command opens the REPL. Every command takes `--json` and `--help`.
54
+
55
+ | Option | Env var | Default |
56
+ | -------------- | ------------------------- | ------------------------ |
57
+ | `--gateway` | `BRISSOTO_PAY_GATEWAY` | `prod` |
58
+ | `--convex-url` | `BRISSOTO_PAY_CONVEX_URL` | the gateway's Convex URL |
59
+ | `--web-url` | `BRISSOTO_PAY_WEB_URL` | the gateway's dashboard |
60
+
61
+ | Gateway | Convex URL | Dashboard |
62
+ | ------- | -------------------------------------- | -------------------------- |
63
+ | `prod` | `https://api-pay.brissoto.com` | `https://pay.brissoto.com` |
64
+ | `dev` | `https://giant-bison-875.convex.cloud` | `http://localhost:4310` |
65
+
66
+ Sessions: `~/.config/brissoto-pay/sessions.json`, one per Convex URL.
67
+
68
+ ## Develop
69
+
70
+ ```sh
71
+ npm run test:cli
72
+ uv run --directory cli cli-anything-brissoto-pay --help
73
+ ```
@@ -0,0 +1,3 @@
1
+ """Brissoto Pay CLI: set apps up in the gateway from a coding agent."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ """`python -m cli_anything.brissoto_pay`."""
2
+
3
+ from .brissoto_pay_cli import main
4
+
5
+ main()
@@ -0,0 +1,195 @@
1
+ """`cli-anything-brissoto-pay`: Click entry point and REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ from typing import NoReturn
7
+
8
+ import click
9
+
10
+ from .commands.apps import apps
11
+ from .commands.go_live import go_live
12
+ from .commands.methods import methods
13
+ from .commands.payments import payments
14
+ from .commands.servers import servers
15
+ from .commands.webhooks import webhooks
16
+ from .core.client import signed_out
17
+ from .core.config import (
18
+ CONVEX_URL_ENV,
19
+ GATEWAY_ENV,
20
+ GATEWAYS,
21
+ PROD,
22
+ WEB_URL_ENV,
23
+ )
24
+ from .core.errors import CliError
25
+ from .core.login import new_secret, pkce_challenge, sign_in_url, wait_for_code
26
+ from .core.runtime import Runtime
27
+ from .utils.options import json_option, pass_runtime
28
+ from .utils.output import emit, emit_error
29
+ from .utils.repl import run_repl
30
+
31
+ class BrissotoPayGroup(click.Group):
32
+ """Turns a `CliError` into the error shape and a non-zero exit."""
33
+
34
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
35
+ ctx.meta["argv"] = list(args)
36
+ try:
37
+ return super().parse_args(ctx, args)
38
+ except click.UsageError as error:
39
+ self._usage_error(ctx, error)
40
+
41
+ def invoke(self, ctx: click.Context):
42
+ runtime = ctx.find_object(Runtime)
43
+ try:
44
+ return super().invoke(ctx)
45
+ except CliError as error:
46
+ emit_error(runtime.json, error)
47
+ ctx.exit(1)
48
+ except click.UsageError as error:
49
+ self._usage_error(ctx, error)
50
+
51
+ def _usage_error(self, ctx: click.Context, error: click.UsageError) -> NoReturn:
52
+ """With `--json`, a usage error in the error shape, exit 2; else Click's own."""
53
+ runtime = ctx.find_object(Runtime)
54
+ if not (runtime.json or "--json" in ctx.meta["argv"]):
55
+ raise error
56
+ emit_error(True, CliError("usage", error.format_message()))
57
+ ctx.exit(2)
58
+
59
+
60
+ @click.group(cls=BrissotoPayGroup, invoke_without_command=True)
61
+ @click.option("--json", "as_json", is_flag=True, help="Answer in JSON.")
62
+ @click.option(
63
+ "--gateway",
64
+ type=click.Choice(list(GATEWAYS)),
65
+ envvar=GATEWAY_ENV,
66
+ default=PROD,
67
+ show_default=True,
68
+ help="Deployment: its Convex URL and dashboard.",
69
+ )
70
+ @click.option(
71
+ "--convex-url",
72
+ envvar=CONVEX_URL_ENV,
73
+ help="Convex URL, instead of the gateway's.",
74
+ )
75
+ @click.option(
76
+ "--web-url",
77
+ envvar=WEB_URL_ENV,
78
+ help="Dashboard that approves the sign-in, instead of the gateway's.",
79
+ )
80
+ @click.pass_context
81
+ def cli(
82
+ ctx: click.Context,
83
+ as_json: bool,
84
+ gateway: str,
85
+ convex_url: str | None,
86
+ web_url: str | None,
87
+ ) -> None:
88
+ """Set apps up in the Brissoto Pay gateway. No command opens the REPL."""
89
+ runtime = ctx.ensure_object(Runtime)
90
+ runtime.json = as_json
91
+ default_convex_url, default_web_url = GATEWAYS[gateway]
92
+ runtime.convex_url = (convex_url or default_convex_url).strip().rstrip("/")
93
+ runtime.web_url = (web_url or default_web_url).strip().rstrip("/")
94
+ if ctx.invoked_subcommand is None and not runtime.in_repl:
95
+ run_repl(ctx, runtime)
96
+
97
+
98
+ @cli.command()
99
+ @json_option
100
+ @pass_runtime
101
+ def skill(runtime: Runtime) -> None:
102
+ """Print the skill that teaches a coding agent the setup flow."""
103
+ text = files(__package__).joinpath("skills/SKILL.md").read_text(encoding="utf-8")
104
+ emit(runtime.json, {"skill": text}, text.rstrip("\n"))
105
+
106
+
107
+ CLI_PROVIDER = "cli"
108
+
109
+ SIGN_IN_REFUSED = (
110
+ "The approval was refused: it expired, was used, or you are no longer an Admin."
111
+ " Run login again."
112
+ )
113
+
114
+
115
+ def _viewer(runtime: Runtime) -> dict:
116
+ viewer = runtime.client().query("users:viewer")
117
+ if not viewer:
118
+ raise signed_out()
119
+ return {
120
+ "email": viewer["email"],
121
+ "name": viewer["name"],
122
+ "gateway": runtime.convex_url,
123
+ }
124
+
125
+
126
+ @cli.command()
127
+ @json_option
128
+ @pass_runtime
129
+ def login(runtime: Runtime) -> None:
130
+ """Sign in: approve in the browser with your Admin account."""
131
+ state = new_secret()
132
+ verifier = new_secret()
133
+ url = sign_in_url(
134
+ runtime.web_url, state, pkce_challenge(verifier), runtime.hostname()
135
+ )
136
+
137
+ def open_page() -> None:
138
+ click.echo(f"Open this page to approve: {url}", err=True)
139
+ try:
140
+ runtime.open_browser(url)
141
+ except Exception:
142
+ pass
143
+
144
+ code = wait_for_code(runtime.callback_port, state, runtime.login_timeout, open_page)
145
+ gateway = runtime.gateway
146
+ gateway.set_auth(None)
147
+ result = gateway.action(
148
+ "auth:signIn",
149
+ {"provider": CLI_PROVIDER, "params": {"code": code, "codeVerifier": verifier}},
150
+ )
151
+ tokens = (result or {}).get("tokens")
152
+ if not tokens:
153
+ raise CliError("sign_in_refused", SIGN_IN_REFUSED)
154
+ runtime.store.put(runtime.convex_url, tokens["token"], tokens["refreshToken"])
155
+ viewer = _viewer(runtime)
156
+ emit(runtime.json, viewer, f"Signed in as {viewer['email']} on {viewer['gateway']}")
157
+
158
+
159
+ @cli.command()
160
+ @json_option
161
+ @pass_runtime
162
+ def logout(runtime: Runtime) -> None:
163
+ """Sign out: end this gateway's session and forget it."""
164
+ if runtime.store.get(runtime.convex_url) is not None:
165
+ try:
166
+ runtime.client().action("auth:signOut")
167
+ except CliError:
168
+ pass
169
+ runtime.store.drop(runtime.convex_url)
170
+ emit(
171
+ runtime.json,
172
+ {"signedOut": True, "gateway": runtime.convex_url},
173
+ f"Signed out of {runtime.convex_url}",
174
+ )
175
+
176
+
177
+ @cli.command()
178
+ @json_option
179
+ @pass_runtime
180
+ def whoami(runtime: Runtime) -> None:
181
+ """Say who is signed in, and on which gateway."""
182
+ viewer = _viewer(runtime)
183
+ emit(runtime.json, viewer, f"{viewer['email']} on {viewer['gateway']}")
184
+
185
+
186
+ cli.add_command(apps)
187
+ cli.add_command(servers)
188
+ cli.add_command(methods)
189
+ cli.add_command(webhooks)
190
+ cli.add_command(payments)
191
+ cli.add_command(go_live)
192
+
193
+
194
+ def main() -> None:
195
+ cli(obj=Runtime())
@@ -0,0 +1 @@
1
+ """The setup commands, one module per group."""
@@ -0,0 +1,144 @@
1
+ """`apps`: find the app this project belongs to, create it, brand it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from ..core.client import Client
10
+ from ..core.config import ENVIRONMENTS, LIVE, TEST
11
+ from ..core.errors import CliError
12
+ from ..core.runtime import Runtime
13
+ from ..utils.options import json_option, pass_runtime
14
+ from ..utils.output import emit, server_lines
15
+
16
+
17
+ @click.group()
18
+ def apps() -> None:
19
+ """List, read, create and brand apps."""
20
+
21
+
22
+ def _state(app: dict) -> str:
23
+ state = LIVE if app["live"] else TEST
24
+ return f"{state}, archived" if app["archived"] else state
25
+
26
+
27
+ @apps.command("list")
28
+ @json_option
29
+ @pass_runtime
30
+ def list_apps(runtime: Runtime) -> None:
31
+ """Every app, archived ones last."""
32
+ found = runtime.client().query("apps/queries:list")
33
+ lines = [f"{app['slug']} {app['name']} {_state(app)}" for app in found]
34
+ emit(runtime.json, found, "\n".join(lines) or "No app yet.")
35
+
36
+
37
+ def app_detail(runtime: Runtime, slug: str) -> dict:
38
+ """The app with its Test and Live sides, or a `not_found` error."""
39
+ detail = runtime.client().query("apps/queries:get", {"slug": slug})
40
+ if detail is None:
41
+ raise CliError("not_found", f"No app {slug}. Run: apps list")
42
+ return detail
43
+
44
+
45
+ @apps.command()
46
+ @click.argument("slug")
47
+ @json_option
48
+ @pass_runtime
49
+ def get(runtime: Runtime, slug: str) -> None:
50
+ """One app with its servers, URLs and key hints, in Test and Live."""
51
+ detail = app_detail(runtime, slug)
52
+ app = detail["app"]
53
+ lines = [
54
+ (
55
+ f"{app['name']} ({app['slug']}): {_state(app)},"
56
+ f" colour {app['color']}, language {app['defaultLanguage']}"
57
+ )
58
+ ]
59
+ for side in ENVIRONMENTS:
60
+ if detail[side]:
61
+ lines.append(f"{side.capitalize()}:")
62
+ for server in detail[side]["servers"]:
63
+ lines.extend(server_lines(server))
64
+ emit(runtime.json, detail, "\n".join(lines))
65
+
66
+
67
+ LANGUAGE = click.Choice(["fr", "en"])
68
+ LOGO = click.Path(dir_okay=False, path_type=Path)
69
+
70
+
71
+ def upload_logo(client: Client, logo: Path) -> str:
72
+ """The logo file in Convex storage; returns its storage id."""
73
+ upload_url = client.mutation("apps/mutations:generateLogoUploadUrl")
74
+ return client.upload(upload_url, logo)
75
+
76
+
77
+ @apps.command()
78
+ @click.option("--name", required=True, help="As payers see it, e.g. Heyama.")
79
+ @click.option("--color", required=True, help="Brand colour, e.g. #0F7B5F.")
80
+ @click.option(
81
+ "--language", required=True, type=LANGUAGE, help="Checkout's default language."
82
+ )
83
+ @click.option("--logo", type=LOGO, help="Logo file, e.g. public/logo.png.")
84
+ @json_option
85
+ @pass_runtime
86
+ def create(
87
+ runtime: Runtime, name: str, color: str, language: str, logo: Path | None
88
+ ) -> None:
89
+ """A new app, in Test with a Dev server and no key yet."""
90
+ client = runtime.client()
91
+ args = {"name": name.strip(), "color": color.strip(), "defaultLanguage": language}
92
+ if logo is not None:
93
+ args["logoId"] = upload_logo(client, logo)
94
+ slug = client.mutation("apps/mutations:create", args)["slug"]
95
+ emit(
96
+ runtime.json,
97
+ {"slug": slug},
98
+ f"Created {slug}, in Test with a Dev server and no key yet. Next: apps get {slug}",
99
+ )
100
+
101
+
102
+ #: What `updateBranding` would clear if left out: sent back as they are.
103
+ KEPT = ("termsUrl", "privacyUrl", "supportEmail", "supportWhatsapp")
104
+
105
+
106
+ @apps.command()
107
+ @click.argument("slug")
108
+ @click.option("--name", help="As payers see it, e.g. Heyama.")
109
+ @click.option("--color", help="Brand colour, e.g. #0F7B5F.")
110
+ @click.option("--language", type=LANGUAGE, help="Checkout's default language.")
111
+ @click.option("--logo", type=LOGO, help="Logo file, e.g. public/logo.png.")
112
+ @json_option
113
+ @pass_runtime
114
+ def branding(
115
+ runtime: Runtime,
116
+ slug: str,
117
+ name: str | None,
118
+ color: str | None,
119
+ language: str | None,
120
+ logo: Path | None,
121
+ ) -> None:
122
+ """Change an app's name, colour, language or logo; the rest stays."""
123
+ if name is None and color is None and language is None and logo is None:
124
+ raise CliError(
125
+ "nothing_to_change",
126
+ "Give at least one of --name, --color, --language, --logo.",
127
+ )
128
+ client = runtime.client()
129
+ app = app_detail(runtime, slug)["app"]
130
+ args = {
131
+ "slug": slug,
132
+ "name": app["name"] if name is None else name.strip(),
133
+ "color": app["color"] if color is None else color.strip(),
134
+ "defaultLanguage": language or app["defaultLanguage"],
135
+ **{field: app[field] for field in KEPT if app[field] is not None},
136
+ }
137
+ if logo is not None:
138
+ args["logoId"] = upload_logo(client, logo)
139
+ client.mutation("apps/mutations:updateBranding", args)
140
+ emit(
141
+ runtime.json,
142
+ {"slug": slug, "updated": True},
143
+ f"Updated the branding of {slug}.",
144
+ )
@@ -0,0 +1,75 @@
1
+ """`go-live`: the app takes real payments. Only after the Admin's yes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.config import LIVE
8
+ from ..core.errors import CliError
9
+ from ..core.runtime import Runtime
10
+ from ..utils.options import json_option, pass_runtime
11
+ from ..utils.output import emit
12
+ from .apps import app_detail
13
+
14
+
15
+ def production_server_id(detail: dict) -> str:
16
+ """The id of a live app's Production server."""
17
+ return detail[LIVE]["servers"][0]["id"]
18
+
19
+
20
+ @click.command("go-live")
21
+ @click.argument("slug")
22
+ @click.option(
23
+ "--webhook-url",
24
+ help="Production webhook URL, e.g. https://heyama.com/api/webhooks.",
25
+ )
26
+ @click.option(
27
+ "--return-url",
28
+ "return_urls",
29
+ multiple=True,
30
+ help="Production return URL, e.g. https://heyama.com. Repeat for more.",
31
+ )
32
+ @click.option("--yes", is_flag=True, help="Go live: the Admin agreed.")
33
+ @json_option
34
+ @pass_runtime
35
+ def go_live(
36
+ runtime: Runtime,
37
+ slug: str,
38
+ webhook_url: str | None,
39
+ return_urls: tuple[str, ...],
40
+ yes: bool,
41
+ ) -> None:
42
+ """Take a Test app live at its Production URLs. Needs --yes."""
43
+ detail = app_detail(runtime, slug)
44
+ if detail[LIVE]:
45
+ raise CliError(
46
+ "already_live",
47
+ f"{slug} is already live. Change its Production URLs with"
48
+ f" servers update {production_server_id(detail)}.",
49
+ )
50
+ webhook = webhook_url.strip() if webhook_url else None
51
+ returns = [url.strip() for url in return_urls]
52
+ args: dict = {"slug": slug, "returnUrls": returns}
53
+ if webhook:
54
+ args["webhookUrl"] = webhook
55
+ client = runtime.client()
56
+ if not yes:
57
+ # Every check of going live, nothing written: a refusal comes before the summary.
58
+ client.mutation("apps/mutations:goLive", {**args, "dryRun": True})
59
+ at = f"webhook URL {webhook}" if webhook else "no webhook URL"
60
+ raise CliError(
61
+ "confirmation_required",
62
+ f"Going live makes {slug} take real payments, with the methods and"
63
+ f" settings it has in Test, on a Production server with {at} and"
64
+ f" return URLs {', '.join(returns) or 'none'}. Its live API key and"
65
+ " webhook secret are shown once. Ask the Admin, then run again with --yes.",
66
+ )
67
+ secrets = client.mutation("apps/mutations:goLive", args)
68
+ server_id = production_server_id(app_detail(runtime, slug))
69
+ emit(
70
+ runtime.json,
71
+ {"slug": slug, "serverId": server_id, **secrets},
72
+ f"{slug} is live. Production server {server_id}, shown this once:\n"
73
+ f"API key: {secrets['apiKey']}\n"
74
+ f"Webhook secret: {secrets['webhookSecret']}",
75
+ )
@@ -0,0 +1,58 @@
1
+ """`methods`: the payment methods, and which ones an app offers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.config import TEST
8
+ from ..core.runtime import Runtime
9
+ from ..utils.options import environment_option, json_option, pass_runtime
10
+ from ..utils.output import emit
11
+
12
+
13
+ @click.group()
14
+ def methods() -> None:
15
+ """List the payment methods; turn one on or off for an app."""
16
+
17
+
18
+ @methods.command("list")
19
+ @environment_option
20
+ @click.option("--app", "slug", help="Say whether this app offers each, e.g. heyama.")
21
+ @json_option
22
+ @pass_runtime
23
+ def list_methods(runtime: Runtime, environment: str, slug: str | None) -> None:
24
+ """Every method in checkout order, with its provider and status."""
25
+ found = runtime.client().query("methods/queries:list", {"environment": environment})
26
+ lines = []
27
+ for row in found:
28
+ served = "by " + row["servedBy"]["name"] if row["servedBy"] else "no provider"
29
+ line = (
30
+ f"{row['id']} {row['name']} {row['country']} {row['currency']}"
31
+ f" {row['status']}, {served}"
32
+ )
33
+ if slug is not None:
34
+ row["on"] = slug not in row["offApps"]
35
+ line += f" {'on' if row['on'] else 'off'} for {slug}"
36
+ lines.append(line)
37
+ emit(runtime.json, found, "\n".join(lines))
38
+
39
+
40
+ @methods.command("set")
41
+ @click.argument("slug")
42
+ @click.argument("method")
43
+ @click.option("--on/--off", "on", default=None, help="Offer it, or not.")
44
+ @json_option
45
+ @pass_runtime
46
+ def set_method(runtime: Runtime, slug: str, method: str, on: bool | None) -> None:
47
+ """Let the app offer a method at checkout in Test, or not. Live follows at go live."""
48
+ if on is None:
49
+ raise click.UsageError("Give --on or --off.")
50
+ runtime.client().mutation(
51
+ "methods/mutations:setAppAccess",
52
+ {"method": method, "environment": TEST, "slug": slug, "enabled": on},
53
+ )
54
+ emit(
55
+ runtime.json,
56
+ {"slug": slug, "method": method, "environment": TEST, "on": on},
57
+ f"{method} is {'on' if on else 'off'} for {slug} in {TEST}.",
58
+ )