impreza-cli 0.5.1__tar.gz → 0.7.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 (66) hide show
  1. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/PKG-INFO +30 -11
  2. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/README.md +28 -9
  3. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/_helpers.py +2 -3
  4. impreza_cli-0.7.0/impreza_cli/commands/config.py +220 -0
  5. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/context.py +18 -0
  6. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/dedicated.py +40 -6
  7. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/vps_cloud.py +1 -0
  8. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/config.py +6 -0
  9. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/main.py +12 -1
  10. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/sdk.py +6 -3
  11. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/state.py +5 -0
  12. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/PKG-INFO +30 -11
  13. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/SOURCES.txt +2 -0
  14. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/requires.txt +1 -1
  15. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/pyproject.toml +1 -1
  16. impreza_cli-0.7.0/tests/test_config_commands.py +297 -0
  17. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_context_commands.py +116 -0
  18. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_dedicated_commands.py +21 -9
  19. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/__init__.py +0 -0
  20. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/__init__.py +0 -0
  21. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/account.py +0 -0
  22. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/catalog.py +0 -0
  23. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/doctor.py +0 -0
  24. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/domain.py +0 -0
  25. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/invoice.py +0 -0
  26. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/key.py +0 -0
  27. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/orders.py +0 -0
  28. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/services.py +0 -0
  29. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/vps.py +0 -0
  30. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/vps_proxmox.py +0 -0
  31. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/webhooks.py +0 -0
  32. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/output.py +0 -0
  33. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/dependency_links.txt +0 -0
  34. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/entry_points.txt +0 -0
  35. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/top_level.txt +0 -0
  36. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/setup.cfg +0 -0
  37. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/.gitkeep +0 -0
  38. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/__init__.py +0 -0
  39. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/conftest.py +0 -0
  40. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_account_commands.py +0 -0
  41. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_account_topup_commands.py +0 -0
  42. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_catalog_commands.py +0 -0
  43. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_config.py +0 -0
  44. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_doctor_commands.py +0 -0
  45. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_domain_commands.py +0 -0
  46. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_invoice_commands.py +0 -0
  47. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_key_commands.py +0 -0
  48. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_orders_commands.py +0 -0
  49. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_2_smoke.py +0 -0
  50. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_3_smoke.py +0 -0
  51. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_4_smoke.py +0 -0
  52. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_5_smoke.py +0 -0
  53. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_6_smoke.py +0 -0
  54. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_1_smoke.py +0 -0
  55. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_2_smoke.py +0 -0
  56. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_3_smoke.py +0 -0
  57. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_4_smoke.py +0 -0
  58. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_5_smoke.py +0 -0
  59. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_6_smoke.py +0 -0
  60. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_7_smoke.py +0 -0
  61. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_services_commands.py +0 -0
  62. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_vps_cloud_commands.py +0 -0
  63. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_vps_commands.py +0 -0
  64. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_vps_proxmox_commands.py +0 -0
  65. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_webhooks_commands.py +0 -0
  66. {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_yaml_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: impreza-cli
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: Official command-line interface for the Impreza Host public REST API
5
5
  Author-email: Impreza Host <support@imprezahost.com>
6
6
  License: MIT
@@ -23,7 +23,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
23
23
  Classifier: Topic :: Utilities
24
24
  Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
- Requires-Dist: impreza-sdk<0.6,>=0.5.0
26
+ Requires-Dist: impreza-sdk<0.8,>=0.7.0
27
27
  Requires-Dist: typer>=0.12
28
28
  Requires-Dist: rich>=13.7
29
29
  Requires-Dist: click>=8.0
@@ -227,23 +227,33 @@ equivalent) and `impreza <TAB>` should suggest resource groups,
227
227
 
228
228
  ## Tor
229
229
 
230
- Inherited from the SDK. Three knobs:
230
+ Four knobs, from the most persistent to the most one-off:
231
231
 
232
232
  ```bash
233
- # Per-context override at create time
234
- impreza context create offshore \
235
- --key imp_... --secret ... \
236
- # No --proxy flag yet; for now, set IMPREZA_USE_TOR before invoking
233
+ # Persisted at context creation (writes [settings] use_tor=true)
234
+ impreza context create offshore --key imp_... --secret ... --via-tor
235
+
236
+ # Explicit SOCKS5 proxy instead (wins over use_tor; works for any
237
+ # SOCKS5, not just Tor)
238
+ impreza context create offshore --key imp_... --secret ... --proxy socks5://127.0.0.1:9050
239
+
240
+ # One-shot global flag on any command
241
+ impreza --via-tor account info
237
242
 
238
243
  # Env var, picked up by the SDK transparently
239
244
  IMPREZA_USE_TOR=1 impreza account info
240
-
241
- # Programmatic via the SDK (Python users skip the CLI for this)
242
245
  ```
243
246
 
247
+ All four route through the local Tor daemon's SOCKS5 port with remote
248
+ DNS (no local lookup of the API hostname). Because exit nodes rotate,
249
+ set the API key's IP factor to `tofu` or `keyonly` in the client area —
250
+ `whitelist` cannot work without a stable egress IP. When the account
251
+ uses the API's `.onion` mirror, the IP factor sees a stable loopback
252
+ instead.
253
+
244
254
  The SDK's `auto_tor=True` path (probe Tor, fall back to clearnet)
245
- isn't surfaced through the CLI yet — coming in a future release
246
- alongside the `--via-tor` shortcut.
255
+ isn't surfaced through the CLI yet — it is a fail-open convenience,
256
+ which is the wrong default here.
247
257
 
248
258
  ## Error handling
249
259
 
@@ -259,6 +269,15 @@ Tracebacks never leak from expected failures (auth errors, missing
259
269
  contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
260
270
  the traceback isn't swallowed — that's intentional.
261
271
 
272
+ ## Reviewed application configuration
273
+
274
+ Version 0.6.0 adds configuration export, review and apply. Documents are limited
275
+ to 64 KiB of UTF-8 and carry secret references instead of values. Applications
276
+ retain their current configuration until a reviewed plan is explicitly applied.
277
+
278
+ See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
279
+ for SDK and CLI examples, permissions, review expiry and replay behavior.
280
+
262
281
  ## Development
263
282
 
264
283
  ```bash
@@ -185,23 +185,33 @@ equivalent) and `impreza <TAB>` should suggest resource groups,
185
185
 
186
186
  ## Tor
187
187
 
188
- Inherited from the SDK. Three knobs:
188
+ Four knobs, from the most persistent to the most one-off:
189
189
 
190
190
  ```bash
191
- # Per-context override at create time
192
- impreza context create offshore \
193
- --key imp_... --secret ... \
194
- # No --proxy flag yet; for now, set IMPREZA_USE_TOR before invoking
191
+ # Persisted at context creation (writes [settings] use_tor=true)
192
+ impreza context create offshore --key imp_... --secret ... --via-tor
193
+
194
+ # Explicit SOCKS5 proxy instead (wins over use_tor; works for any
195
+ # SOCKS5, not just Tor)
196
+ impreza context create offshore --key imp_... --secret ... --proxy socks5://127.0.0.1:9050
197
+
198
+ # One-shot global flag on any command
199
+ impreza --via-tor account info
195
200
 
196
201
  # Env var, picked up by the SDK transparently
197
202
  IMPREZA_USE_TOR=1 impreza account info
198
-
199
- # Programmatic via the SDK (Python users skip the CLI for this)
200
203
  ```
201
204
 
205
+ All four route through the local Tor daemon's SOCKS5 port with remote
206
+ DNS (no local lookup of the API hostname). Because exit nodes rotate,
207
+ set the API key's IP factor to `tofu` or `keyonly` in the client area —
208
+ `whitelist` cannot work without a stable egress IP. When the account
209
+ uses the API's `.onion` mirror, the IP factor sees a stable loopback
210
+ instead.
211
+
202
212
  The SDK's `auto_tor=True` path (probe Tor, fall back to clearnet)
203
- isn't surfaced through the CLI yet — coming in a future release
204
- alongside the `--via-tor` shortcut.
213
+ isn't surfaced through the CLI yet — it is a fail-open convenience,
214
+ which is the wrong default here.
205
215
 
206
216
  ## Error handling
207
217
 
@@ -217,6 +227,15 @@ Tracebacks never leak from expected failures (auth errors, missing
217
227
  contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
218
228
  the traceback isn't swallowed — that's intentional.
219
229
 
230
+ ## Reviewed application configuration
231
+
232
+ Version 0.6.0 adds configuration export, review and apply. Documents are limited
233
+ to 64 KiB of UTF-8 and carry secret references instead of values. Applications
234
+ retain their current configuration until a reviewed plan is explicitly applied.
235
+
236
+ See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
237
+ for SDK and CLI examples, permissions, review expiry and replay behavior.
238
+
220
239
  ## Development
221
240
 
222
241
  ```bash
@@ -28,11 +28,10 @@ Conventions kept consistent across every CLI command:
28
28
  from __future__ import annotations
29
29
 
30
30
  import time
31
- from typing import Any
32
31
  from urllib.parse import quote
33
32
 
34
33
  import typer
35
- from impreza import Operation, Vps
34
+ from impreza import Client, Operation, Vps
36
35
  from impreza.exceptions import ApiError, InvalidRequest, ResourceNotFound
37
36
 
38
37
  from ..output import error
@@ -85,7 +84,7 @@ def exit_on_api_error(exc: ApiError) -> None:
85
84
  raise typer.Exit(code=1)
86
85
 
87
86
 
88
- def resolve_vps_or_exit(client: Any, service_id: int) -> Vps:
87
+ def resolve_vps_or_exit(client: Client, service_id: int) -> Vps:
89
88
  """Look up a VPS by id, mapping the standard SDK errors to the
90
89
  same friendly stderr lines the 3.2 power verbs introduced.
91
90
 
@@ -0,0 +1,220 @@
1
+ """``impreza config`` — configuration as code for custom deployments (P4).
2
+
3
+ Three commands over the reviewed config surface:
4
+
5
+ * ``impreza config export <deployment> [--out FILE]`` — write the versioned
6
+ document (secrets as references) to stdout or a file;
7
+ * ``impreza config apply <deployment> --file FILE [--yes]`` — prepare a
8
+ reviewed apply from the document, show the server-computed diff, and only
9
+ queue it after an explicit confirmation. The apply carries the exact
10
+ review digest, so what you confirmed is byte-for-byte what runs;
11
+ * ``impreza config plan <plan-id>`` — read a saved review's status or
12
+ replay its receipt.
13
+
14
+ The document is meant for version control: export it, commit it, edit it,
15
+ apply it. Secret values never appear in the document — applying resolves
16
+ the references server-side.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import stat
24
+ from pathlib import Path
25
+
26
+ import typer
27
+ from impreza.exceptions import ApiError
28
+
29
+ from ..output import error, success
30
+ from ..sdk import make_client_or_exit
31
+ from ..state import confirm_or_exit, from_typer_context
32
+ from ._helpers import exit_on_api_error
33
+
34
+ app = typer.Typer(
35
+ name="config",
36
+ help="Export, review and apply versioned application configuration.",
37
+ no_args_is_help=True,
38
+ )
39
+
40
+
41
+ def _terminal_text(value: str) -> str:
42
+ # Escape terminal control characters and bidi overrides in server text.
43
+ return "".join(
44
+ ch
45
+ if ch in "\n\t"
46
+ or (
47
+ ord(ch) >= 32
48
+ and not 127 <= ord(ch) <= 159
49
+ and not 0x202A <= ord(ch) <= 0x202E
50
+ and not 0x2066 <= ord(ch) <= 0x2069
51
+ )
52
+ else f"\\u{ord(ch):04x}"
53
+ for ch in value
54
+ )
55
+
56
+
57
+ @app.command("export")
58
+ def export(
59
+ typer_ctx: typer.Context,
60
+ deployment_id: str = typer.Argument(..., help="Custom deployment id (dpl_…)."),
61
+ out: Path | None = typer.Option(
62
+ None,
63
+ "--out",
64
+ "-o",
65
+ help="Write the document to this file instead of stdout.",
66
+ ),
67
+ ) -> None:
68
+ """Export the versioned config document (secrets as references)."""
69
+ client = make_client_or_exit(from_typer_context(typer_ctx))
70
+ try:
71
+ payload = client.config.export(deployment_id)
72
+ except ApiError as exc:
73
+ exit_on_api_error(exc)
74
+ except ValueError as exc:
75
+ error(str(exc))
76
+ raise typer.Exit(1) from exc
77
+ document_text = payload.get("document_text")
78
+ if not isinstance(document_text, str) or not document_text.strip():
79
+ error("The export carried no document text.")
80
+ raise typer.Exit(1)
81
+ if out is not None:
82
+ out.write_text(document_text, encoding="utf-8")
83
+ success(
84
+ f"Exported config for {payload.get('deployment_id', deployment_id)} "
85
+ f"(schema {payload.get('schema', '?')}) to {out}."
86
+ )
87
+ return
88
+ typer.echo(document_text, nl=False)
89
+
90
+
91
+ @app.command("apply")
92
+ def apply(
93
+ typer_ctx: typer.Context,
94
+ deployment_id: str = typer.Argument(..., help="Custom deployment id (dpl_…)."),
95
+ file: Path = typer.Option(
96
+ ...,
97
+ "--file",
98
+ "-f",
99
+ help="Config document to apply (as written by `impreza config export`).",
100
+ exists=True,
101
+ readable=True,
102
+ dir_okay=False,
103
+ ),
104
+ yes: bool = typer.Option(
105
+ False,
106
+ "--yes",
107
+ "-y",
108
+ help="Apply without the interactive confirmation prompt.",
109
+ ),
110
+ ) -> None:
111
+ """Prepare a reviewed apply from a document, show its diff, confirm, apply."""
112
+ try:
113
+ if not file.is_file():
114
+ raise ValueError("Config input must be a regular file.")
115
+ # Nonblocking open also prevents a raced replacement with a FIFO from
116
+ # hanging automation. Validate the opened descriptor, then bound reads.
117
+ fd = os.open(file, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0))
118
+ with os.fdopen(fd, "rb") as stream:
119
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
120
+ raise ValueError("Config input must be a regular file.")
121
+ raw = stream.read(65537)
122
+ if not raw or len(raw) > 65536:
123
+ raise ValueError("Config document must contain 1 to 65536 UTF-8 bytes.")
124
+ document = raw.decode("utf-8")
125
+ except (OSError, ValueError) as exc:
126
+ error("Cannot read config document: " + str(exc))
127
+ raise typer.Exit(1) from exc
128
+ client = make_client_or_exit(from_typer_context(typer_ctx))
129
+ try:
130
+ review = client.config.prepare(deployment_id, document)
131
+ except ApiError as exc:
132
+ exit_on_api_error(exc)
133
+ except ValueError as exc:
134
+ error(str(exc))
135
+ raise typer.Exit(1) from exc
136
+ plan_id = review.get("plan_id")
137
+ review_digest = review.get("review_digest")
138
+ changes = review.get("changes")
139
+ diff_text = review.get("diff_text")
140
+ if (
141
+ not isinstance(plan_id, str)
142
+ or re.fullmatch(r"cplan_[a-f0-9]{24}", plan_id) is None
143
+ or not isinstance(review_digest, str)
144
+ or re.fullmatch(r"[a-f0-9]{64}", review_digest) is None
145
+ or review.get("deployment_id") != deployment_id
146
+ or review.get("operation") != "config_apply"
147
+ or not isinstance(changes, list)
148
+ or not all(isinstance(item, str) for item in changes)
149
+ or not isinstance(diff_text, str)
150
+ or review.get("no_changes") is not (changes == [])
151
+ ):
152
+ error(
153
+ "The review response is incomplete or does not match this deployment; "
154
+ "nothing was applied."
155
+ )
156
+ raise typer.Exit(1)
157
+
158
+ if isinstance(changes, (list, dict)) and changes:
159
+ typer.secho("Planned changes:", fg=typer.colors.YELLOW, bold=True)
160
+ if isinstance(changes, dict):
161
+ for key, value in changes.items():
162
+ typer.echo(f" {key}: {value}")
163
+ else:
164
+ for line in changes:
165
+ typer.echo(_terminal_text(f" {line}"))
166
+ else:
167
+ typer.echo("No changes detected — the apply is idempotent for this document.")
168
+ if isinstance(diff_text, str) and diff_text.strip():
169
+ typer.echo("")
170
+ typer.secho("Diff:", fg=typer.colors.YELLOW, bold=True)
171
+ typer.echo(_terminal_text(diff_text.rstrip()))
172
+
173
+ typer.echo("")
174
+ confirm_or_exit(f"Apply this configuration to {deployment_id}.", yes=yes)
175
+ try:
176
+ result = client.config.apply(plan_id, review_digest)
177
+ except ApiError as exc:
178
+ exit_on_api_error(exc)
179
+ except ValueError as exc:
180
+ error(str(exc))
181
+ raise typer.Exit(1) from exc
182
+ receipt = result.get("receipt")
183
+ if not isinstance(receipt, dict):
184
+ receipt = result
185
+ command_id = result.get("command_id") or receipt.get("command_id")
186
+ replayed = bool(result.get("replayed"))
187
+ if replayed:
188
+ success(f"Plan {plan_id} was already accepted — replayed its receipt.")
189
+ else:
190
+ success(f"Configuration applied (plan {plan_id}).")
191
+ if isinstance(command_id, str) and command_id:
192
+ typer.echo(_terminal_text(f"Deploy command: {command_id}"))
193
+ if receipt.get("no_changes") is True:
194
+ typer.echo("No deployment was queued because the configuration already matches.")
195
+ else:
196
+ typer.echo("The change is queued; watch the deployment's status or the command above.")
197
+
198
+
199
+ @app.command("plan")
200
+ def plan(
201
+ typer_ctx: typer.Context,
202
+ plan_id: str = typer.Argument(..., help="Config plan id (cplan_…)."),
203
+ ) -> None:
204
+ """Read one saved config review: status, digest and receipt."""
205
+ client = make_client_or_exit(from_typer_context(typer_ctx))
206
+ try:
207
+ payload = client.config.get_plan(plan_id)
208
+ except ApiError as exc:
209
+ exit_on_api_error(exc)
210
+ except ValueError as exc:
211
+ error(str(exc))
212
+ raise typer.Exit(1) from exc
213
+ status = payload.get("status", "unknown")
214
+ typer.echo(_terminal_text(f"Plan {plan_id}: {status}"))
215
+ digest = payload.get("review_digest")
216
+ if isinstance(digest, str) and digest:
217
+ typer.echo(_terminal_text(f"Review digest: {digest}"))
218
+ receipt = payload.get("receipt")
219
+ if isinstance(receipt, dict):
220
+ typer.echo(f"Receipt: {receipt}")
@@ -88,6 +88,18 @@ def create(
88
88
  help="Optional default output format for this context.",
89
89
  case_sensitive=False,
90
90
  ),
91
+ via_tor: bool = typer.Option(
92
+ False,
93
+ "--via-tor",
94
+ help="Route API calls through the local Tor daemon "
95
+ "(sets [settings] use_tor=true, CLI-wide).",
96
+ ),
97
+ proxy: str | None = typer.Option(
98
+ None,
99
+ "--proxy",
100
+ help="Explicit SOCKS5 proxy, e.g. socks5://127.0.0.1:9050 "
101
+ "(sets [settings] proxy, CLI-wide; wins over use_tor).",
102
+ ),
91
103
  overwrite: bool = typer.Option(
92
104
  False,
93
105
  "--overwrite",
@@ -108,6 +120,12 @@ def create(
108
120
  )
109
121
  except (ContextAlreadyExists, InvalidContextName) as exc:
110
122
  _exit_on_config_error(exc)
123
+
124
+ if via_tor or proxy:
125
+ if via_tor:
126
+ cfg.settings.use_tor = True
127
+ if proxy:
128
+ cfg.settings.proxy = proxy
111
129
  cfg.save()
112
130
 
113
131
  is_default = cfg.default_context == name
@@ -45,7 +45,7 @@ Reinstall (destructive — wipes ALL data):
45
45
 
46
46
  from __future__ import annotations
47
47
 
48
- from typing import Any
48
+ from typing import Any, Literal, cast
49
49
 
50
50
  import typer
51
51
  from impreza.exceptions import ApiError
@@ -76,9 +76,11 @@ def _emit(value: Any, *, ctx: typer.Context, title: str) -> None:
76
76
  """
77
77
  fmt = resolve_output(from_typer_context(ctx), None)
78
78
  if isinstance(value, list):
79
- rows = value if all(isinstance(row, dict) for row in value) else [
80
- {"value": item} for item in value
81
- ]
79
+ rows = (
80
+ value
81
+ if all(isinstance(row, dict) for row in value)
82
+ else [{"value": item} for item in value]
83
+ )
82
84
  print_table(title, rows, fmt=fmt)
83
85
  elif isinstance(value, dict):
84
86
  print_dict(title, value, fmt=fmt)
@@ -356,8 +358,15 @@ def cmd_set_firewall(
356
358
  """Update DDoS firewall state/sensitivity for an IP. Requires the ``firewall`` capability."""
357
359
  client = make_client_or_exit(from_typer_context(ctx))
358
360
  try:
361
+ if state not in (None, "always_on", "redirect_on_attack"):
362
+ raise typer.BadParameter("Use always_on or redirect_on_attack.", param_hint="--state")
363
+ if sensitivity not in (None, "low", "normal", "medium", "high"):
364
+ raise typer.BadParameter("Use low, normal, medium or high.", param_hint="--sensitivity")
359
365
  data = client.dedicated.set_firewall(
360
- service_id, ip=ip, state=state, sensitivity=sensitivity
366
+ service_id,
367
+ ip=ip,
368
+ state=cast(Literal["always_on", "redirect_on_attack"] | None, state),
369
+ sensitivity=cast(Literal["low", "normal", "medium", "high"] | None, sensitivity),
361
370
  )
362
371
  except ApiError as exc:
363
372
  exit_on_api_error(exc)
@@ -382,7 +391,32 @@ def cmd_bandwidth(
382
391
  """Bandwidth graph (PNG base64). Requires the ``bandwidth`` capability."""
383
392
  client = make_client_or_exit(from_typer_context(ctx))
384
393
  try:
385
- data = client.dedicated.bandwidth(service_id, type=type, scale=scale)
394
+ if type not in (
395
+ "port_bits",
396
+ "port_upkts",
397
+ "port_percent",
398
+ "port_errors",
399
+ "port_pktsize",
400
+ "port_discards",
401
+ ):
402
+ raise typer.BadParameter("Unsupported bandwidth type.", param_hint="--type")
403
+ if scale not in ("day", "week", "month"):
404
+ raise typer.BadParameter("Use day, week or month.", param_hint="--scale")
405
+ data = client.dedicated.bandwidth(
406
+ service_id,
407
+ type=cast(
408
+ Literal[
409
+ "port_bits",
410
+ "port_upkts",
411
+ "port_percent",
412
+ "port_errors",
413
+ "port_pktsize",
414
+ "port_discards",
415
+ ],
416
+ type,
417
+ ),
418
+ scale=cast(Literal["day", "week", "month"], scale),
419
+ )
386
420
  except ApiError as exc:
387
421
  exit_on_api_error(exc)
388
422
  return
@@ -178,6 +178,7 @@ def images_list(
178
178
  if not images:
179
179
  typer.echo("No images saved on this account.")
180
180
  return
181
+ rows: list[dict[str, Any]]
181
182
  if fmt is OutputFormat.TABLE:
182
183
  rows = [
183
184
  {
@@ -167,6 +167,7 @@ class _Settings:
167
167
 
168
168
  poll_interval: float | None = None
169
169
  use_tor: bool | None = None
170
+ proxy: str | None = None
170
171
 
171
172
  def to_toml_dict(self) -> dict[str, Any]:
172
173
  body: dict[str, Any] = {}
@@ -174,6 +175,8 @@ class _Settings:
174
175
  body["poll_interval"] = self.poll_interval
175
176
  if self.use_tor is not None:
176
177
  body["use_tor"] = self.use_tor
178
+ if self.proxy is not None:
179
+ body["proxy"] = self.proxy
177
180
  return body
178
181
 
179
182
 
@@ -267,6 +270,9 @@ class Config:
267
270
  tor = settings_raw.get("use_tor")
268
271
  if isinstance(tor, bool):
269
272
  settings.use_tor = tor
273
+ proxy = settings_raw.get("proxy")
274
+ if isinstance(proxy, str) and proxy:
275
+ settings.proxy = proxy
270
276
 
271
277
  default_context = raw.get("default_context")
272
278
  if not isinstance(default_context, str):
@@ -18,6 +18,7 @@ import typer
18
18
  from . import __version__
19
19
  from .commands import account as account_cmd
20
20
  from .commands import catalog as catalog_cmd
21
+ from .commands import config as config_cmd
21
22
  from .commands import context as context_cmd
22
23
  from .commands import dedicated as dedicated_cmd
23
24
  from .commands import doctor as doctor_cmd
@@ -61,6 +62,15 @@ def _root(
61
62
  "-c",
62
63
  help="Override the default context for this invocation.",
63
64
  ),
65
+ via_tor: bool = typer.Option(
66
+ False,
67
+ "--via-tor",
68
+ help=(
69
+ "Route this invocation through the local Tor daemon "
70
+ "(SOCKS5 127.0.0.1:9050). Overrides [settings] use_tor once; "
71
+ "persist it with `impreza context create --via-tor`."
72
+ ),
73
+ ),
64
74
  output: OutputFormat | None = typer.Option(
65
75
  None,
66
76
  "--output",
@@ -77,7 +87,7 @@ def _root(
77
87
  Global flags ``--context`` and ``--output`` apply to every
78
88
  subcommand and are inherited via Typer's context object.
79
89
  """
80
- ctx.obj = GlobalState(context_override=context, output=output)
90
+ ctx.obj = GlobalState(context_override=context, output=output, via_tor=via_tor)
81
91
 
82
92
 
83
93
  # ── Subcommand mounts ────────────────────────────────────────────────
@@ -86,6 +96,7 @@ def _root(
86
96
  # ``impreza --help`` output reads predictably.
87
97
  app.add_typer(account_cmd.app, name="account")
88
98
  app.add_typer(catalog_cmd.app, name="catalog")
99
+ app.add_typer(config_cmd.app, name="config")
89
100
  app.add_typer(context_cmd.app, name="context")
90
101
  app.add_typer(dedicated_cmd.app, name="dedicated")
91
102
  app.add_typer(doctor_cmd.app, name="doctor")
@@ -69,10 +69,13 @@ def make_client(
69
69
  if ctx.base_url:
70
70
  kwargs["base_url"] = ctx.base_url
71
71
 
72
- # Tor preference comes from the [settings] block (CLI-wide). A
73
- # future per-context override (one VPN context, one clearnet, etc.)
72
+ # Tor/proxy preference: an explicit [settings] proxy wins; then the
73
+ # one-shot global --via-tor flag; then the CLI-wide use_tor setting.
74
+ # A future per-context override (one VPN context, one clearnet, etc.)
74
75
  # would slot in here without touching callers.
75
- if cfg.settings.use_tor:
76
+ if cfg.settings.proxy:
77
+ kwargs["proxy"] = cfg.settings.proxy
78
+ elif state.via_tor or cfg.settings.use_tor:
76
79
  kwargs["use_tor"] = True
77
80
 
78
81
  return Client(**kwargs)
@@ -31,6 +31,11 @@ class GlobalState:
31
31
  #: ``None`` means "use the config file's ``default_context``".
32
32
  context_override: str | None = None
33
33
 
34
+ #: One-shot Tor routing selected via the global ``--via-tor`` flag.
35
+ #: Overrides the config file's ``[settings] use_tor`` for this
36
+ #: invocation only; ``None``/False means "follow the config".
37
+ via_tor: bool = False
38
+
34
39
  #: Default output format selected via the global ``--output`` flag.
35
40
  #: Per-command ``--output`` flags can override this on a single
36
41
  #: invocation; ``None`` here means "no preference, fall back to
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: impreza-cli
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: Official command-line interface for the Impreza Host public REST API
5
5
  Author-email: Impreza Host <support@imprezahost.com>
6
6
  License: MIT
@@ -23,7 +23,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
23
23
  Classifier: Topic :: Utilities
24
24
  Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
- Requires-Dist: impreza-sdk<0.6,>=0.5.0
26
+ Requires-Dist: impreza-sdk<0.8,>=0.7.0
27
27
  Requires-Dist: typer>=0.12
28
28
  Requires-Dist: rich>=13.7
29
29
  Requires-Dist: click>=8.0
@@ -227,23 +227,33 @@ equivalent) and `impreza <TAB>` should suggest resource groups,
227
227
 
228
228
  ## Tor
229
229
 
230
- Inherited from the SDK. Three knobs:
230
+ Four knobs, from the most persistent to the most one-off:
231
231
 
232
232
  ```bash
233
- # Per-context override at create time
234
- impreza context create offshore \
235
- --key imp_... --secret ... \
236
- # No --proxy flag yet; for now, set IMPREZA_USE_TOR before invoking
233
+ # Persisted at context creation (writes [settings] use_tor=true)
234
+ impreza context create offshore --key imp_... --secret ... --via-tor
235
+
236
+ # Explicit SOCKS5 proxy instead (wins over use_tor; works for any
237
+ # SOCKS5, not just Tor)
238
+ impreza context create offshore --key imp_... --secret ... --proxy socks5://127.0.0.1:9050
239
+
240
+ # One-shot global flag on any command
241
+ impreza --via-tor account info
237
242
 
238
243
  # Env var, picked up by the SDK transparently
239
244
  IMPREZA_USE_TOR=1 impreza account info
240
-
241
- # Programmatic via the SDK (Python users skip the CLI for this)
242
245
  ```
243
246
 
247
+ All four route through the local Tor daemon's SOCKS5 port with remote
248
+ DNS (no local lookup of the API hostname). Because exit nodes rotate,
249
+ set the API key's IP factor to `tofu` or `keyonly` in the client area —
250
+ `whitelist` cannot work without a stable egress IP. When the account
251
+ uses the API's `.onion` mirror, the IP factor sees a stable loopback
252
+ instead.
253
+
244
254
  The SDK's `auto_tor=True` path (probe Tor, fall back to clearnet)
245
- isn't surfaced through the CLI yet — coming in a future release
246
- alongside the `--via-tor` shortcut.
255
+ isn't surfaced through the CLI yet — it is a fail-open convenience,
256
+ which is the wrong default here.
247
257
 
248
258
  ## Error handling
249
259
 
@@ -259,6 +269,15 @@ Tracebacks never leak from expected failures (auth errors, missing
259
269
  contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
260
270
  the traceback isn't swallowed — that's intentional.
261
271
 
272
+ ## Reviewed application configuration
273
+
274
+ Version 0.6.0 adds configuration export, review and apply. Documents are limited
275
+ to 64 KiB of UTF-8 and carry secret references instead of values. Applications
276
+ retain their current configuration until a reviewed plan is explicitly applied.
277
+
278
+ See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
279
+ for SDK and CLI examples, permissions, review expiry and replay behavior.
280
+
262
281
  ## Development
263
282
 
264
283
  ```bash