impreza-cli 0.3.0__py3-none-any.whl
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.
- impreza_cli/__init__.py +14 -0
- impreza_cli/commands/__init__.py +7 -0
- impreza_cli/commands/_helpers.py +128 -0
- impreza_cli/commands/account.py +576 -0
- impreza_cli/commands/catalog.py +270 -0
- impreza_cli/commands/context.py +232 -0
- impreza_cli/commands/doctor.py +427 -0
- impreza_cli/commands/domain.py +858 -0
- impreza_cli/commands/invoice.py +198 -0
- impreza_cli/commands/key.py +104 -0
- impreza_cli/commands/orders.py +478 -0
- impreza_cli/commands/services.py +100 -0
- impreza_cli/commands/vps.py +812 -0
- impreza_cli/commands/vps_cloud.py +865 -0
- impreza_cli/commands/vps_proxmox.py +727 -0
- impreza_cli/commands/webhooks.py +483 -0
- impreza_cli/config.py +405 -0
- impreza_cli/main.py +100 -0
- impreza_cli/output.py +207 -0
- impreza_cli/sdk.py +97 -0
- impreza_cli/state.py +94 -0
- impreza_cli-0.3.0.dist-info/METADATA +296 -0
- impreza_cli-0.3.0.dist-info/RECORD +26 -0
- impreza_cli-0.3.0.dist-info/WHEEL +5 -0
- impreza_cli-0.3.0.dist-info/entry_points.txt +2 -0
- impreza_cli-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""``impreza doctor`` health-check command — Phase 4.1.
|
|
2
|
+
|
|
3
|
+
A single-command first-line support tool. Runs a sequence of
|
|
4
|
+
checks against the resolved context and reports the state of
|
|
5
|
+
each, so a user troubleshooting "I just installed and nothing
|
|
6
|
+
works" gets a copy-pasteable diagnostic instead of digging
|
|
7
|
+
through eight different verbs.
|
|
8
|
+
|
|
9
|
+
Checks (in order, stopping early on hard failures):
|
|
10
|
+
|
|
11
|
+
1. **Config file resolved** — does ``~/.config/impreza/config.toml``
|
|
12
|
+
exist and parse? (Implicit via ``make_client_or_exit``; failures
|
|
13
|
+
land as the standard config-error stderr.)
|
|
14
|
+
2. **Active context** — which context resolved? Show its label
|
|
15
|
+
and the API key prefix.
|
|
16
|
+
3. **API reachable** — round-trip ``GET /account/api-keys/self``.
|
|
17
|
+
Network errors and auth errors both surface here.
|
|
18
|
+
4. **Key status** — the returned ``KeyIdentity.status`` field.
|
|
19
|
+
5. **IP whitelist** — does the server-observed ``request_ip`` match
|
|
20
|
+
one of the whitelist entries? Mismatch is the single most
|
|
21
|
+
common "everything is 403" cause.
|
|
22
|
+
6. **Account profile + balance** — round-trip ``GET /account``.
|
|
23
|
+
Sanity check that the key has actual scopes beyond
|
|
24
|
+
``/account/api-keys/self``.
|
|
25
|
+
|
|
26
|
+
Each check renders as ``[OK] / [FAIL] / [WARN]`` (ASCII-only —
|
|
27
|
+
no Unicode glyphs, per the Phase 1.6 cp1252 lesson). The exit
|
|
28
|
+
code is 0 if every check passed, 1 otherwise. Output mode
|
|
29
|
+
``--output json`` emits a structured array suitable for piping
|
|
30
|
+
into monitoring scripts.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import typer
|
|
41
|
+
from impreza.exceptions import (
|
|
42
|
+
ApiError,
|
|
43
|
+
AuthError,
|
|
44
|
+
IpNotWhitelisted,
|
|
45
|
+
NetworkError,
|
|
46
|
+
PermissionDenied,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from ..output import OutputFormat, error, success, warning
|
|
50
|
+
from ..sdk import make_client_or_exit
|
|
51
|
+
from ..state import from_typer_context, resolve_output
|
|
52
|
+
|
|
53
|
+
app = typer.Typer(
|
|
54
|
+
name="doctor",
|
|
55
|
+
help="Run a health check against the active context.",
|
|
56
|
+
invoke_without_command=True,
|
|
57
|
+
no_args_is_help=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── data model ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _CheckResult:
|
|
65
|
+
"""One row in the doctor report. Mutable so callers can build it
|
|
66
|
+
in-place across the check body.
|
|
67
|
+
|
|
68
|
+
``ok`` is True/False/None — None means "skipped" (typically
|
|
69
|
+
because an earlier check failed and this one depends on it).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
__slots__ = ("name", "ok", "summary", "detail")
|
|
73
|
+
|
|
74
|
+
def __init__(self, name: str) -> None:
|
|
75
|
+
self.name: str = name
|
|
76
|
+
self.ok: bool | None = False
|
|
77
|
+
self.summary: str = ""
|
|
78
|
+
self.detail: str = ""
|
|
79
|
+
|
|
80
|
+
def passed(self, summary: str, detail: str = "") -> None:
|
|
81
|
+
self.ok = True
|
|
82
|
+
self.summary = summary
|
|
83
|
+
self.detail = detail
|
|
84
|
+
|
|
85
|
+
def failed(self, summary: str, detail: str = "") -> None:
|
|
86
|
+
self.ok = False
|
|
87
|
+
self.summary = summary
|
|
88
|
+
self.detail = detail
|
|
89
|
+
|
|
90
|
+
def warned(self, summary: str, detail: str = "") -> None:
|
|
91
|
+
# WARN renders distinctly but still counts as "passed" for
|
|
92
|
+
# the overall exit code. Used for cosmetic mismatches that
|
|
93
|
+
# don't actually break functionality.
|
|
94
|
+
self.ok = True
|
|
95
|
+
self.summary = "WARN: " + summary
|
|
96
|
+
self.detail = detail
|
|
97
|
+
|
|
98
|
+
def skipped(self, summary: str) -> None:
|
|
99
|
+
self.ok = None
|
|
100
|
+
self.summary = summary
|
|
101
|
+
self.detail = ""
|
|
102
|
+
|
|
103
|
+
def as_dict(self) -> dict[str, Any]:
|
|
104
|
+
return {
|
|
105
|
+
"name": self.name,
|
|
106
|
+
"ok": self.ok,
|
|
107
|
+
"summary": self.summary,
|
|
108
|
+
"detail": self.detail,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── individual checks ───────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_context(state: Any) -> _CheckResult:
|
|
116
|
+
"""Report which context resolved. Doesn't actually verify the
|
|
117
|
+
API key — that's the next check's job. The ``make_client_or_exit``
|
|
118
|
+
call in the parent already failed-fast on missing-config errors,
|
|
119
|
+
so reaching this check at all means there's a usable context."""
|
|
120
|
+
r = _CheckResult("active-context")
|
|
121
|
+
override = state.context_override
|
|
122
|
+
if override:
|
|
123
|
+
r.passed(f"Context override: {override!r}")
|
|
124
|
+
else:
|
|
125
|
+
r.passed("Default context")
|
|
126
|
+
return r
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_api_reachable(client: Any, key_holder: dict[str, Any]) -> _CheckResult:
|
|
130
|
+
"""Round-trip GET /account/api-keys/self. Captures elapsed time
|
|
131
|
+
so the report shows latency, and stashes the result in
|
|
132
|
+
``key_holder`` so later checks reuse it without a second HTTP."""
|
|
133
|
+
r = _CheckResult("api-reachable")
|
|
134
|
+
t0 = time.monotonic()
|
|
135
|
+
try:
|
|
136
|
+
key = client.account.api_key_self()
|
|
137
|
+
except NetworkError as exc:
|
|
138
|
+
r.failed(
|
|
139
|
+
"Could not reach api.imprezahost.com",
|
|
140
|
+
f"Network error: {exc}. Check connectivity / DNS / proxy. "
|
|
141
|
+
"If using Tor, confirm the SOCKS proxy is up.",
|
|
142
|
+
)
|
|
143
|
+
return r
|
|
144
|
+
except AuthError as exc:
|
|
145
|
+
r.failed(
|
|
146
|
+
"Authentication failed (HTTP 401)",
|
|
147
|
+
f"{exc.message}. The API key or secret is invalid. "
|
|
148
|
+
"Rotate via Impreza Account, update the local context "
|
|
149
|
+
"with `impreza context create / use`.",
|
|
150
|
+
)
|
|
151
|
+
return r
|
|
152
|
+
except IpNotWhitelisted as exc:
|
|
153
|
+
r.failed(
|
|
154
|
+
"IP not whitelisted (HTTP 403)",
|
|
155
|
+
f"{exc.message}. Add the calling IP to the API key's "
|
|
156
|
+
"whitelist via Impreza Account, or use a different "
|
|
157
|
+
"key whose whitelist already covers this IP.",
|
|
158
|
+
)
|
|
159
|
+
return r
|
|
160
|
+
except PermissionDenied as exc:
|
|
161
|
+
r.failed(
|
|
162
|
+
"Permission denied (HTTP 403)",
|
|
163
|
+
f"{exc.message}.",
|
|
164
|
+
)
|
|
165
|
+
return r
|
|
166
|
+
except ApiError as exc:
|
|
167
|
+
r.failed(
|
|
168
|
+
f"API error: {exc.message}",
|
|
169
|
+
f"code={exc.code or '?'}, status={exc.status_code or '?'}",
|
|
170
|
+
)
|
|
171
|
+
return r
|
|
172
|
+
elapsed_ms = (time.monotonic() - t0) * 1000.0
|
|
173
|
+
key_holder["key"] = key
|
|
174
|
+
r.passed(
|
|
175
|
+
f"GET /account/api-keys/self OK ({elapsed_ms:.0f}ms)",
|
|
176
|
+
f"key prefix={key.prefix!r}, label={key.label or '(unnamed)'!r}",
|
|
177
|
+
)
|
|
178
|
+
return r
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _check_key_status(key_holder: dict[str, Any]) -> _CheckResult:
|
|
182
|
+
"""Inspect the KeyIdentity.status field returned by api_key_self.
|
|
183
|
+
Active keys pass; anything else (paused, revoked, etc.) flags as
|
|
184
|
+
a hard fail — even though the call succeeded, the key may stop
|
|
185
|
+
working at any time."""
|
|
186
|
+
r = _CheckResult("key-status")
|
|
187
|
+
key = key_holder.get("key")
|
|
188
|
+
if key is None:
|
|
189
|
+
r.skipped("api-reachable check failed; cannot inspect key")
|
|
190
|
+
return r
|
|
191
|
+
status = (key.status or "").lower()
|
|
192
|
+
if status == "active":
|
|
193
|
+
r.passed(f"status={status!r}")
|
|
194
|
+
else:
|
|
195
|
+
r.failed(
|
|
196
|
+
f"status={key.status!r} (not active)",
|
|
197
|
+
"Pending, paused, or revoked keys will start returning "
|
|
198
|
+
"401 unpredictably. Rotate via Impreza Account.",
|
|
199
|
+
)
|
|
200
|
+
return r
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_ip_whitelist(key_holder: dict[str, Any]) -> _CheckResult:
|
|
204
|
+
"""Compare ``request_ip`` (what the server saw the call coming
|
|
205
|
+
from) to ``ip_whitelist`` (the entries the server would accept)."""
|
|
206
|
+
r = _CheckResult("ip-whitelist")
|
|
207
|
+
key = key_holder.get("key")
|
|
208
|
+
if key is None:
|
|
209
|
+
r.skipped("api-reachable check failed; cannot inspect whitelist")
|
|
210
|
+
return r
|
|
211
|
+
|
|
212
|
+
request_ip = key.request_ip or ""
|
|
213
|
+
entries = list(key.ip_whitelist or [])
|
|
214
|
+
if not request_ip:
|
|
215
|
+
r.warned(
|
|
216
|
+
"server did not echo request_ip in this response",
|
|
217
|
+
"Whitelist check is unavailable; the call already "
|
|
218
|
+
"succeeded so the IP must be allowed, but the report "
|
|
219
|
+
"cannot prove it.",
|
|
220
|
+
)
|
|
221
|
+
return r
|
|
222
|
+
if not entries:
|
|
223
|
+
r.warned(
|
|
224
|
+
f"request_ip {request_ip} reached the API but the key "
|
|
225
|
+
"has no whitelist entries",
|
|
226
|
+
"Either the key has whitelist enforcement disabled "
|
|
227
|
+
"(unusual) or the server is letting it through anyway. "
|
|
228
|
+
"Inspect via Impreza Account to be sure.",
|
|
229
|
+
)
|
|
230
|
+
return r
|
|
231
|
+
|
|
232
|
+
match = next((e for e in entries if e.ip_address == request_ip), None)
|
|
233
|
+
if match is None:
|
|
234
|
+
labels = ", ".join(
|
|
235
|
+
f"{e.ip_address!r}{f' ({e.label!r})' if e.label else ''}"
|
|
236
|
+
for e in entries
|
|
237
|
+
)
|
|
238
|
+
r.failed(
|
|
239
|
+
f"request_ip {request_ip} not in whitelist "
|
|
240
|
+
f"({len(entries)} entr{'y' if len(entries) == 1 else 'ies'})",
|
|
241
|
+
f"Whitelist: {labels}. Add the calling IP via your Impreza "
|
|
242
|
+
"Account, or switch to a context whose key already "
|
|
243
|
+
"allows this IP.",
|
|
244
|
+
)
|
|
245
|
+
return r
|
|
246
|
+
|
|
247
|
+
label = f" ({match.label!r})" if match.label else ""
|
|
248
|
+
r.passed(f"request_ip {request_ip} matches entry{label}")
|
|
249
|
+
return r
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _check_account_profile(client: Any) -> _CheckResult:
|
|
253
|
+
"""Round-trip GET /account. The api_key_self endpoint sometimes
|
|
254
|
+
bypasses scope checks; calling /account exercises a regular
|
|
255
|
+
read-scope so the doctor catches scope-limited keys early."""
|
|
256
|
+
r = _CheckResult("account-profile")
|
|
257
|
+
try:
|
|
258
|
+
acc = client.account.get()
|
|
259
|
+
except ApiError as exc:
|
|
260
|
+
r.failed(
|
|
261
|
+
f"GET /account failed: {exc.message}",
|
|
262
|
+
f"code={exc.code or '?'}, status={exc.status_code or '?'}. "
|
|
263
|
+
"If api-reachable passed but this didn't, the key may "
|
|
264
|
+
"lack the basic read scope. Contact support.",
|
|
265
|
+
)
|
|
266
|
+
return r
|
|
267
|
+
name = f"{acc.first_name} {acc.last_name}".strip()
|
|
268
|
+
if acc.company:
|
|
269
|
+
name = f"{name} ({acc.company})"
|
|
270
|
+
r.passed(
|
|
271
|
+
f"{name} <{acc.email}>, balance {acc.balance:.2f} {acc.currency}",
|
|
272
|
+
f"registered {acc.registered_at}",
|
|
273
|
+
)
|
|
274
|
+
return r
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── renderers ───────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_LABEL_COLOR = {
|
|
281
|
+
True: typer.colors.GREEN,
|
|
282
|
+
False: typer.colors.RED,
|
|
283
|
+
None: typer.colors.YELLOW,
|
|
284
|
+
}
|
|
285
|
+
_LABEL_TEXT = {True: "[OK] ", False: "[FAIL]", None: "[SKIP]"}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_warn(result: _CheckResult) -> bool:
|
|
289
|
+
return result.ok is True and result.summary.startswith("WARN:")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _print_text_report(results: list[_CheckResult]) -> None:
|
|
293
|
+
"""Write the human-friendly report to stdout. Failures and warns
|
|
294
|
+
get their detail indented under the summary line so a copy-paste
|
|
295
|
+
of the whole report preserves the full context."""
|
|
296
|
+
sys.stdout.write("\n")
|
|
297
|
+
sys.stdout.write("impreza doctor\n")
|
|
298
|
+
sys.stdout.write("-" * 40 + "\n")
|
|
299
|
+
for r in results:
|
|
300
|
+
if _is_warn(r):
|
|
301
|
+
label = "[WARN]"
|
|
302
|
+
color = typer.colors.YELLOW
|
|
303
|
+
else:
|
|
304
|
+
label = _LABEL_TEXT[r.ok]
|
|
305
|
+
color = _LABEL_COLOR[r.ok]
|
|
306
|
+
typer.secho(label, fg=color, bold=True, nl=False)
|
|
307
|
+
sys.stdout.write(f" {r.name}: {r.summary}\n")
|
|
308
|
+
if r.detail:
|
|
309
|
+
for line in r.detail.splitlines():
|
|
310
|
+
sys.stdout.write(f" {line}\n")
|
|
311
|
+
sys.stdout.write("-" * 40 + "\n")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _print_summary(results: list[_CheckResult]) -> int:
|
|
315
|
+
"""Bottom-line summary. Returns the exit code (0 if all pass).
|
|
316
|
+
|
|
317
|
+
Uses the :mod:`commands.output` palette helpers (4.3) so the
|
|
318
|
+
summary line matches the colour conventions every other CLI
|
|
319
|
+
command will adopt going forward: success = green, warning =
|
|
320
|
+
yellow, error = red.
|
|
321
|
+
"""
|
|
322
|
+
total = len(results)
|
|
323
|
+
failed = [r for r in results if r.ok is False]
|
|
324
|
+
skipped = [r for r in results if r.ok is None]
|
|
325
|
+
warned = [r for r in results if _is_warn(r)]
|
|
326
|
+
passed = total - len(failed) - len(skipped)
|
|
327
|
+
if failed:
|
|
328
|
+
error(f"{len(failed)} of {total} checks failed.")
|
|
329
|
+
return 1
|
|
330
|
+
if warned or skipped:
|
|
331
|
+
msg_parts = [f"{passed} of {total} checks passed"]
|
|
332
|
+
if warned:
|
|
333
|
+
msg_parts.append(f"{len(warned)} warning(s)")
|
|
334
|
+
if skipped:
|
|
335
|
+
msg_parts.append(f"{len(skipped)} skipped")
|
|
336
|
+
warning(", ".join(msg_parts) + ".")
|
|
337
|
+
return 0
|
|
338
|
+
success(f"All checks passed. {passed}/{total}.")
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── entry point ─────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@app.callback(invoke_without_command=True)
|
|
346
|
+
def doctor(
|
|
347
|
+
typer_ctx: typer.Context,
|
|
348
|
+
output: OutputFormat | None = typer.Option(
|
|
349
|
+
None,
|
|
350
|
+
"--output",
|
|
351
|
+
"-o",
|
|
352
|
+
help=(
|
|
353
|
+
"Output format. 'json' emits the check array structured "
|
|
354
|
+
"for monitoring scripts (each entry has name / ok / "
|
|
355
|
+
"summary / detail). Default: human-friendly text report."
|
|
356
|
+
),
|
|
357
|
+
case_sensitive=False,
|
|
358
|
+
),
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Run a health check against the active context.
|
|
361
|
+
|
|
362
|
+
Reports each step (config / context / API reachable / key
|
|
363
|
+
status / IP whitelist / account) and exits 0 only if every
|
|
364
|
+
check passed. ``--output json`` produces a structured report
|
|
365
|
+
suitable for piping into monitoring scripts.
|
|
366
|
+
|
|
367
|
+
Common failure modes the doctor catches:
|
|
368
|
+
|
|
369
|
+
* Config file missing or empty → first-time setup hint.
|
|
370
|
+
* API key invalid → rotate via your Impreza Account.
|
|
371
|
+
* IP not whitelisted → add the IP or switch context.
|
|
372
|
+
* Key paused / revoked → rotate.
|
|
373
|
+
* Account-scope mismatch → contact support.
|
|
374
|
+
"""
|
|
375
|
+
# If a subcommand was invoked, do nothing — Typer routes there.
|
|
376
|
+
if typer_ctx.invoked_subcommand is not None:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
state = from_typer_context(typer_ctx)
|
|
380
|
+
fmt = resolve_output(state, output)
|
|
381
|
+
|
|
382
|
+
results: list[_CheckResult] = []
|
|
383
|
+
|
|
384
|
+
# Check 1: config + context (failures here exit before we can
|
|
385
|
+
# build a Client; the parent helper renders its own error).
|
|
386
|
+
results.append(_check_context(state))
|
|
387
|
+
|
|
388
|
+
# Build the client. make_client_or_exit handles config errors
|
|
389
|
+
# itself with a friendly stderr + Exit(1), so reaching this point
|
|
390
|
+
# means we have a valid client.
|
|
391
|
+
with make_client_or_exit(state) as client:
|
|
392
|
+
key_holder: dict[str, Any] = {}
|
|
393
|
+
results.append(_check_api_reachable(client, key_holder))
|
|
394
|
+
results.append(_check_key_status(key_holder))
|
|
395
|
+
results.append(_check_ip_whitelist(key_holder))
|
|
396
|
+
# Account profile only makes sense if API was reachable.
|
|
397
|
+
if key_holder.get("key") is not None:
|
|
398
|
+
results.append(_check_account_profile(client))
|
|
399
|
+
else:
|
|
400
|
+
r = _CheckResult("account-profile")
|
|
401
|
+
r.skipped("api-reachable failed; cannot test other endpoints")
|
|
402
|
+
results.append(r)
|
|
403
|
+
|
|
404
|
+
if fmt is OutputFormat.JSON:
|
|
405
|
+
sys.stdout.write(
|
|
406
|
+
json.dumps([r.as_dict() for r in results], indent=2, ensure_ascii=False)
|
|
407
|
+
)
|
|
408
|
+
sys.stdout.write("\n")
|
|
409
|
+
# JSON consumers want exit-code-as-signal too.
|
|
410
|
+
if any(r.ok is False for r in results):
|
|
411
|
+
raise typer.Exit(code=1)
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
_print_text_report(results)
|
|
415
|
+
exit_code = _print_summary(results)
|
|
416
|
+
if exit_code != 0:
|
|
417
|
+
# Don't suppress the report — raise after printing so the
|
|
418
|
+
# user sees the full diagnostic AND gets the non-zero exit.
|
|
419
|
+
# typer.Exit raises a SystemExit(exit_code) which click /
|
|
420
|
+
# CliRunner translates to result.exit_code.
|
|
421
|
+
raise typer.Exit(code=exit_code)
|
|
422
|
+
# Some terminals expect a trailing newline before the prompt.
|
|
423
|
+
# The summary printer already emitted one via secho.
|
|
424
|
+
|
|
425
|
+
# Note: we don't use `error()` for failed checks because the
|
|
426
|
+
# report itself contains the [FAIL] markers in red — adding a
|
|
427
|
+
# stderr "Error:" line on top would just duplicate the signal.
|