20 KiB
Code Review — CLI
| Field | Value |
|---|---|
| Module | src/ScadaLink.CLI |
| Design doc | docs/requirements/Component-CLI.md |
| Status | Reviewed |
| Last reviewed | 2026-05-16 |
| Reviewer | claude-agent |
| Commit reviewed | 9c60592 |
| Open findings | 12 |
Summary
The CLI is a small, well-structured HTTP client over the Management API. The command-tree
construction is consistent and repetitive in a good way: every subcommand funnels through
CommandHelpers.ExecuteCommandAsync, which centralizes URL/credential resolution, HTTP
dispatch, and response handling. There are no Akka.NET concerns (the CLI is a pure HTTP
client) and no concurrency-sensitive code apart from the debug stream SignalR handler.
The dominant theme is graceful-degradation gaps: several user-supplied inputs (malformed
URLs, malformed --bindings/--overrides JSON, non-JSON success bodies) are deserialized
or constructed without try/catch, so a normal user mistake surfaces as an unhandled
exception with a stack trace instead of a clean error message and exit code 1. A second
theme is dead configuration: the SCADALINK_FORMAT environment variable and the
defaultFormat config-file field are loaded by CliConfig but never consulted by any
command, so the documented format-precedence chain does not work. The third theme is
substantial design-document drift: Component-CLI.md describes a name-keyed,
--file-based command surface that bears little resemblance to the implemented
ID-keyed, flag-based surface. Test coverage exercises OutputFormatter, CliConfig, and
CommandHelpers.HandleResponse, but the HTTP client, the debug stream path, the JSON
argument parsing, and the command-tree wiring are untested.
Checklist coverage
| # | Category | Examined | Notes |
|---|---|---|---|
| 1 | Correctness & logic bugs | ☑ | Format precedence is broken (CLI-001); empty/non-JSON success bodies crash table rendering (CLI-002, CLI-003). |
| 2 | Akka.NET conventions | ☑ | Not applicable — CLI is a pure HTTP/SignalR client with no Akka.NET runtime (design doc confirms). No issues. |
| 3 | Concurrency & thread safety | ☑ | Only debug stream is concurrent; CancellationTokenSource is never disposed (CLI-011). Exit-code resolution after Ctrl+C is loose (CLI-012). |
| 4 | Error handling & resilience | ☑ | Unhandled exceptions on malformed URL (CLI-004) and malformed JSON arguments (CLI-005); StartAsync cancellation is misreported (CLI-010). |
| 5 | Security | ☑ | --password on the command line leaks into process listings / shell history with no env-var or prompt alternative (CLI-006). |
| 6 | Performance & resource management | ☑ | HttpClient per invocation is acceptable for a one-shot CLI. CancellationTokenSource leak noted in CLI-011. |
| 7 | Design-document adherence | ☑ | Component-CLI.md is heavily stale relative to the implemented command surface (CLI-007). |
| 8 | Code organization & conventions | ☑ | Consistent and clean; CliConfig.DefaultFormat is loaded but unused (covered by CLI-001). Minor: --format not validated (CLI-008). |
| 9 | Testing coverage | ☑ | No tests for ManagementHttpClient, DebugCommands, command-tree wiring, or JSON argument parsing (CLI-013). |
| 10 | Documentation & comments | ☑ | Component-CLI.md mismatch (CLI-007); the in-repo README.md is reasonably accurate. Minor exit-code doc mismatch (CLI-009). |
Findings
CLI-001 — SCADALINK_FORMAT env var and config-file format are dead; format precedence broken
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | src/ScadaLink.CLI/Commands/CommandHelpers.cs:18, src/ScadaLink.CLI/Commands/DebugCommands.cs:45, src/ScadaLink.CLI/CliConfig.cs:37-39 |
Description
CliConfig.Load() reads SCADALINK_FORMAT and the defaultFormat config-file field into
CliConfig.DefaultFormat, and Component-CLI.md documents a format-precedence chain
(command-line option → env var → config file). However, every command resolves the format
with var format = result.GetValue(formatOption) ?? "json"; and formatOption is created
in Program.cs:11 with DefaultValueFactory = _ => "json". GetValue therefore always
returns a non-null value ("json" when the flag is absent), so the ?? "json" fallback never
fires and config.DefaultFormat is never consulted. The env var and config-file format
settings are dead code: scadalink site list always outputs JSON regardless of
SCADALINK_FORMAT=table or a defaultFormat entry in ~/.scadalink/config.json. The
documented behaviour silently does not work.
Recommendation
Either remove the --format option's DefaultValueFactory and have CommandHelpers
resolve precedence explicitly (result.GetValue(formatOption) → config.DefaultFormat),
or detect whether the option was explicitly supplied (result.GetResult(formatOption)) and
only then override the config value. Apply the same fix to DebugCommands.BuildStream.
Resolution
Resolved 2026-05-16 (commit <pending>). Removed the --format option's
DefaultValueFactory in Program.cs and added CommandHelpers.ResolveFormat, which uses
ParseResult.GetResult(formatOption) to detect an explicitly supplied flag and resolves
precedence explicitly: explicit --format → CliConfig.DefaultFormat (env var / config
file) → "json". Both CommandHelpers.ExecuteCommandAsync and DebugCommands.BuildStream
now call ResolveFormat. Regression tests added in FormatResolutionTests.
CLI-002 — Empty success body crashes table rendering with an unhandled exception
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | src/ScadaLink.CLI/Commands/CommandHelpers.cs:59-68, src/ScadaLink.CLI/Commands/CommandHelpers.cs:78-80 |
Description
ManagementHttpClient.SendCommandAsync returns JsonData = responseBody for any
success status code, including a 200/204 with an empty body. HandleResponse then tests
response.JsonData != null — an empty string is non-null — and for --format table
calls WriteAsTable(response.JsonData), which immediately does JsonDocument.Parse(json).
JsonDocument.Parse("") throws JsonException, which is not caught anywhere, so a
command that legitimately returns no body (e.g. a delete that returns 204) terminates with
a stack trace instead of a clean success message.
Recommendation
In HandleResponse, treat a null-or-whitespace JsonData as a "command succeeded, no
output" case (print nothing or (ok)), and return 0 before attempting to parse.
Resolution
Unresolved.
CLI-003 — Non-JSON success body crashes table rendering
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Open |
| Location | src/ScadaLink.CLI/Commands/CommandHelpers.cs:80 |
Description
WriteAsTable calls JsonDocument.Parse(json) with no try/catch. If the server returns
a success status but a body that is not valid JSON (a proxy/HTML error page returned with
a 200, a plain-text message, etc.), the CLI throws an unhandled JsonException. The
error-path code in ManagementHttpClient (lines 52-61) already defensively wraps
JsonDocument.Parse in a try/catch; the success path and WriteAsTable do not get the
same treatment.
Recommendation
Wrap the JsonDocument.Parse in WriteAsTable in a try/catch; on failure, fall back to
printing the raw body verbatim (as the JSON path already does at line 66).
Resolution
Unresolved.
CLI-004 — Malformed --url throws an unhandled UriFormatException
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Open |
| Location | src/ScadaLink.CLI/ManagementHttpClient.cs:13 |
Description
The ManagementHttpClient constructor does new Uri(baseUrl.TrimEnd('/') + "/") with no
validation. If the user passes a malformed URL (e.g. --url localhost:9001 without a
scheme, or --url ""), new Uri(...) throws UriFormatException. This call is not
guarded by the try/catch in SendCommandAsync (it happens in the constructor at
CommandHelpers.cs:50), so a common typo terminates the CLI with a stack trace rather
than the documented "connection failure → exit 1 with a descriptive message".
Recommendation
Validate the URL before constructing the client — e.g. Uri.TryCreate(url, UriKind.Absolute, out _) in CommandHelpers.ExecuteCommandAsync and DebugCommands.BuildStream — and emit a
clean INVALID_URL error with exit code 1 on failure.
Resolution
Unresolved.
CLI-005 — Malformed --bindings / --overrides JSON throws unhandled exceptions
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Open |
| Location | src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58, src/ScadaLink.CLI/Commands/InstanceCommands.cs:181-182 |
Description
set-bindings deserializes the --bindings argument with
JsonSerializer.Deserialize<List<List<JsonElement>>>(...) and then indexes p[0]/p[1]
and calls p[0].GetString()! / p[1].GetInt32(). set-overrides deserializes --overrides
with JsonSerializer.Deserialize<Dictionary<string, string?>>(...). None of this is wrapped
in a try/catch. Invalid JSON throws JsonException; a pair with fewer than two elements
throws ArgumentOutOfRangeException; a non-string/non-int element throws InvalidOperationException. All of these surface as raw stack traces, so a user typo in a JSON argument
crashes the CLI instead of producing a clean validation error and exit code 1.
Recommendation
Wrap the parsing in try/catch (JsonException ...) (and guard the pair length / element
kinds), and on failure call OutputFormatter.WriteError(...) with an INVALID_ARGUMENT
code and return 1.
Resolution
Unresolved.
CLI-006 — Password is passed as a command-line argument with no safer alternative
| Severity | Medium |
| Category | Security |
| Status | Open |
| Location | src/ScadaLink.CLI/Program.cs:9, src/ScadaLink.CLI/Commands/CommandHelpers.cs:36-44 |
Description
Credentials are supplied only via --username / --password. A password on the command
line is visible to any local user via the process list (ps, /proc/<pid>/cmdline) and is
typically persisted into shell history. Unlike the management URL — which can also come
from SCADALINK_MANAGEMENT_URL or the config file — there is no environment-variable
fallback, no --password-stdin, and no interactive prompt for the password. For a tool
explicitly intended for CI/CD automation this materially increases the chance of credential
leakage.
Recommendation
Add a SCADALINK_PASSWORD environment variable fallback and/or a --password-stdin
option (read the password from stdin), and document that --password on the command line
is discouraged. Optionally prompt interactively when stdin is a TTY and no password was
supplied.
Resolution
Unresolved.
CLI-007 — Component-CLI.md command surface is substantially stale
| Severity | Medium |
| Category | Design-document adherence |
| Status | Open |
| Location | docs/requirements/Component-CLI.md:51-211 (vs. all files under src/ScadaLink.CLI/Commands/) |
Description
The "Command Structure" section of the design doc no longer matches the implemented CLI. Examples of the drift:
- The doc keys most operations by name (
template get <name>,instance get <code>,site get <site-id>); the implementation keys everything by integer ID via--id(TemplateCommands.cs:40,InstanceCommands.cs:31,SiteCommands.cs:26). - The doc shows
template create ... --file <path>andsite update <site-id> --file <path>; the implementation has no--fileoption anywhere and instead takes individual flags (TemplateCommands.cs:52-72,SiteCommands.cs:83-115). - The doc lists commands that do not exist (
template diff,instance bind-connections,instance assign-area,template attribute add --tag-path,data-connection assign/unassign,security api-key enable/disableas separate commands) and omits commands that do exist (instance alarm-override set/delete/list,external-system methodsubgroup). - The doc's
notification smtp update --filediffers from the implemented--server/--port/--auth-mode/--from-addressflags (NotificationCommands.cs:72-94). - The doc uses
--sitefor site identification in several places where the implementation uses--site-idor--identifier.
A reader following the design doc would be unable to drive the CLI.
Recommendation
Regenerate the "Command Structure" section of Component-CLI.md from the actual command
tree (the in-repo src/ScadaLink.CLI/README.md is much closer to reality and could be the
source), or mark the doc's command list as illustrative and point to the README as
authoritative.
Resolution
Unresolved.
CLI-008 — --format value is not validated
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| Location | src/ScadaLink.CLI/Program.cs:10-11, src/ScadaLink.CLI/Commands/CommandHelpers.cs:60 |
Description
The --format option accepts any string. HandleResponse only checks
string.Equals(format, "table", ...); any other value — including a typo like
--format tabel or --format xml — silently falls through to JSON output. The user gets
no feedback that their requested format was not honoured.
Recommendation
Restrict the option to the accepted values, e.g. formatOption.AcceptOnlyFromAmong("json", "table"), so System.CommandLine rejects invalid input with a clear parse error.
Resolution
Unresolved.
CLI-009 — Exit-code documentation does not match HandleResponse behaviour
| Severity | Low |
| Category | Documentation & comments |
| Status | Open |
| Location | docs/requirements/Component-CLI.md:238-249, src/ScadaLink.CLI/Commands/CommandHelpers.cs:75 |
Description
The design doc's Exit Codes table defines code 2 as "Authorization failure (insufficient
role)" and the Error Handling section says "If the server returns HTTP 403, the CLI exits
with code 2." HandleResponse implements return response.StatusCode == 403 ? 2 : 1;,
which is correct for the HTTP error path. However, the NO_URL, NO_CREDENTIALS,
INVALID_OPERATION (from set-bindings/set-overrides) and any other client-side failure
all return 1, and a connection failure carries StatusCode == 0 — none of which the doc
enumerates. More importantly, an authorization failure that the server signals with a body
code of UNAUTHORIZED but an HTTP status other than 403 would be classified as a generic
error (exit 1). The mapping is purely status-driven and the doc does not state that.
Recommendation
Either document precisely that exit code 2 is determined solely by HTTP 403, or key the
"authorization failure" exit code off the response code field as well. Align the doc
with whichever is chosen.
Resolution
Unresolved.
CLI-010 — debug stream reports Ctrl+C during connect as a connection failure
| Severity | Low |
| Category | Error handling & resilience |
| Status | Open |
| Location | src/ScadaLink.CLI/Commands/DebugCommands.cs:181-189 |
Description
StreamDebugAsync calls await connection.StartAsync(cts.Token) inside a
try { } catch (Exception ex) that unconditionally reports
"Connection failed: {ex.Message}" with code CONNECTION_FAILED and returns 1. If the
user presses Ctrl+C while the connection is still being established, cts is cancelled and
StartAsync throws OperationCanceledException; this is caught by the generic handler and
misreported as a connection failure (with exit code 1) rather than a clean user-initiated
cancellation (exit code 0).
Recommendation
Catch OperationCanceledException separately (return 0 quietly) before the generic
catch (Exception) handler, mirroring how the exitTcs.Task.WaitAsync(cts.Token) path at
lines 209-215 already treats cancellation as graceful.
Resolution
Unresolved.
CLI-011 — CancellationTokenSource in debug stream is never disposed
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Location | src/ScadaLink.CLI/Commands/DebugCommands.cs:89 |
Description
var cts = new CancellationTokenSource(); is created in StreamDebugAsync but never
disposed; there is no using declaration and no explicit Dispose() call on any exit
path. CancellationTokenSource owns a WaitHandle and should be disposed. The impact is
small because the process exits shortly after, but it is an IDisposable left undisposed,
contrary to the review checklist's resource-management expectation.
Recommendation
Declare it as using var cts = new CancellationTokenSource(); (or wrap the method body in
a try/finally).
Resolution
Unresolved.
CLI-012 — debug stream exit code is unreliable after stream termination
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Open |
| Location | src/ScadaLink.CLI/Commands/DebugCommands.cs:208-227 |
Description
After await exitTcs.Task.WaitAsync(cts.Token), the method returns
exitTcs.Task.IsCompletedSuccessfully ? exitTcs.Task.Result : 0. When the user cancels
with Ctrl+C, WaitAsync throws OperationCanceledException and exitTcs is typically
still incomplete, so the method returns 0 — correct. However, the OnStreamTerminated
handler and the Closed handler both call exitTcs.TrySetResult, and these run on
SignalR callback threads concurrently with the Ctrl+C path. If a stream termination and a
Ctrl+C race, the final exit code depends on which TrySetResult won and whether
WaitAsync observed completion before cancellation — the result is not deterministic. A
stream the server terminated abnormally can end up returning 0.
Recommendation
Resolve the exit code from a single authoritative source: after the try/catch around
WaitAsync, check exitTcs.Task completion explicitly and treat a Ctrl+C with no prior
result as 0, but always prefer a result that was set by OnStreamTerminated/Closed.
Consider awaiting exitTcs.Task without the cancellation token after a brief grace period.
Resolution
Unresolved.
CLI-013 — HTTP client, debug stream, and JSON-argument parsing are untested
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Location | tests/ScadaLink.CLI.Tests/ (vs. src/ScadaLink.CLI/ManagementHttpClient.cs, src/ScadaLink.CLI/Commands/DebugCommands.cs, src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58) |
Description
The test project covers OutputFormatter, CliConfig.Load, and
CommandHelpers.HandleResponse. It does not cover:
ManagementHttpClient.SendCommandAsync— the timeout (504), connection-failure (code 0), and error-body-parsing paths are untested.- The
debug streamSignalR command — no tests at all. - The JSON-argument parsing in
InstanceCommands(set-bindings,set-overrides) — the paths most likely to crash on bad input (CLI-005) have no coverage. - Command-tree wiring — there is no test asserting that each
Buildproduces the expected subcommands/options or that the command-name derivation (ManagementCommandRegistry.GetCommandName) resolves for every command type the CLI constructs.
Recommendation
Add tests for ManagementHttpClient (using a stub HttpMessageHandler), for the
JSON-argument parsing helpers (extracting the parsing into testable methods), and a
smoke test that walks the root command tree and asserts every leaf command's payload type
resolves via ManagementCommandRegistry.
Resolution
Unresolved.