fix(cli): resolve CLI-002..007 — robust response rendering, URL/JSON arg validation, credential env-vars, doc refresh

This commit is contained in:
Joseph Doherty
2026-05-16 20:58:03 -04:00
parent 658b659c0c
commit 738e67acc5
15 changed files with 685 additions and 150 deletions

View File

@@ -7,6 +7,20 @@ public class CliConfig
public string? ManagementUrl { get; set; }
public string DefaultFormat { get; set; } = "json";
/// <summary>
/// LDAP username from the <c>SCADALINK_USERNAME</c> environment variable, if set.
/// Credentials are intentionally only sourced from environment variables (or the
/// command line) — never from the config file — so they are not persisted to disk.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// LDAP password from the <c>SCADALINK_PASSWORD</c> environment variable, if set.
/// Provides a safer alternative to <c>--password</c>, which leaks into process
/// listings and shell history.
/// </summary>
public string? Password { get; set; }
public static CliConfig Load()
{
var config = new CliConfig();
@@ -38,6 +52,15 @@ public class CliConfig
if (!string.IsNullOrEmpty(envFormat))
config.DefaultFormat = envFormat;
// Credentials from environment variables only (never the config file).
var envUsername = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
if (!string.IsNullOrEmpty(envUsername))
config.Username = envUsername;
var envPassword = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
if (!string.IsNullOrEmpty(envPassword))
config.Password = envPassword;
return config;
}

View File

@@ -31,14 +31,23 @@ internal static class CommandHelpers
return 1;
}
// Validate credentials
var username = result.GetValue(usernameOption);
var password = result.GetValue(passwordOption);
if (!IsValidManagementUrl(url))
{
OutputFormatter.WriteError(
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
"INVALID_URL");
return 1;
}
// Resolve credentials: command-line options take precedence, then the
// SCADALINK_USERNAME / SCADALINK_PASSWORD environment variables.
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = ResolveCredential(result.GetValue(passwordOption), config.Password);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username and --password options.",
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
@@ -74,10 +83,40 @@ internal static class CommandHelpers
return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat;
}
/// <summary>
/// Resolves a single credential: an explicit command-line value wins, otherwise the
/// environment-variable fallback (from <see cref="CliConfig"/>) is used.
/// </summary>
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
/// <summary>
/// Validates that a management URL is an absolute http/https URL. A malformed URL
/// (missing scheme, empty, or a non-http scheme) would otherwise reach
/// <c>new Uri(...)</c> in the <see cref="ManagementHttpClient"/> constructor and throw
/// an unhandled <see cref="UriFormatException"/>.
/// </summary>
internal static bool IsValidManagementUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return false;
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
internal static int HandleResponse(ManagementResponse response, string format)
{
if (response.JsonData != null)
{
// A success status with an empty/whitespace body (e.g. a 204 from a delete)
// is a "command succeeded, no output" case — do not attempt to parse it.
if (string.IsNullOrWhiteSpace(response.JsonData))
{
Console.WriteLine("(ok)");
return 0;
}
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
{
WriteAsTable(response.JsonData);
@@ -98,46 +137,62 @@ internal static class CommandHelpers
private static void WriteAsTable(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Array)
JsonDocument doc;
try
{
var items = root.EnumerateArray().ToList();
if (items.Count == 0)
{
Console.WriteLine("(no results)");
return;
}
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
// The server returned a success status but a non-JSON body (e.g. a proxy
// HTML error page, or a plain-text message). Print it verbatim rather than
// crashing — mirrors the raw-body fallback on the JSON path.
Console.WriteLine(json);
return;
}
var headers = items[0].ValueKind == JsonValueKind.Object
? items[0].EnumerateObject().Select(p => p.Name).ToArray()
: new[] { "Value" };
using (doc)
{
var root = doc.RootElement;
var rows = items.Select(item =>
if (root.ValueKind == JsonValueKind.Array)
{
if (item.ValueKind == JsonValueKind.Object)
var items = root.EnumerateArray().ToList();
if (items.Count == 0)
{
return headers.Select(h =>
item.TryGetProperty(h, out var val)
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
: "").ToArray();
Console.WriteLine("(no results)");
return;
}
return new[] { item.ToString() };
});
OutputFormatter.WriteTable(rows, headers);
}
else if (root.ValueKind == JsonValueKind.Object)
{
var headers = new[] { "Property", "Value" };
var rows = root.EnumerateObject().Select(p =>
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
OutputFormatter.WriteTable(rows, headers);
}
else
{
Console.WriteLine(root.ToString());
var headers = items[0].ValueKind == JsonValueKind.Object
? items[0].EnumerateObject().Select(p => p.Name).ToArray()
: new[] { "Value" };
var rows = items.Select(item =>
{
if (item.ValueKind == JsonValueKind.Object)
{
return headers.Select(h =>
item.TryGetProperty(h, out var val)
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
: "").ToArray();
}
return new[] { item.ToString() };
});
OutputFormatter.WriteTable(rows, headers);
}
else if (root.ValueKind == JsonValueKind.Object)
{
var headers = new[] { "Property", "Value" };
var rows = root.EnumerateObject().Select(p =>
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
OutputFormatter.WriteTable(rows, headers);
}
else
{
Console.WriteLine(root.ToString());
}
}
}
}

View File

@@ -57,13 +57,21 @@ public static class DebugCommands
return 1;
}
var username = result.GetValue(usernameOption);
var password = result.GetValue(passwordOption);
if (!CommandHelpers.IsValidManagementUrl(url))
{
OutputFormatter.WriteError(
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
"INVALID_URL");
return 1;
}
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username and --password options.",
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}

View File

@@ -52,17 +52,106 @@ public static class InstanceCommands
{
var id = result.GetValue(idOption);
var bindingsJson = result.GetValue(bindingsOption)!;
var pairs = System.Text.Json.JsonSerializer.Deserialize<List<List<System.Text.Json.JsonElement>>>(bindingsJson)
?? throw new InvalidOperationException("Invalid bindings JSON");
var bindings = pairs.Select(p =>
(p[0].GetString()!, p[1].GetInt32())).ToList();
if (!TryParseBindings(bindingsJson, out var bindings, out var error))
{
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetConnectionBindingsCommand(id, bindings));
new SetConnectionBindingsCommand(id, bindings!));
});
return cmd;
}
/// <summary>
/// Parses the <c>--bindings</c> argument — a JSON array of
/// <c>[attributeName, dataConnectionId]</c> pairs — into a typed list.
/// Returns <c>false</c> with a descriptive <paramref name="error"/> instead of
/// throwing when the JSON is malformed, a pair has the wrong arity, or an element
/// has the wrong type.
/// </summary>
internal static bool TryParseBindings(
string json,
out List<(string, int)>? bindings,
out string? error)
{
bindings = null;
error = null;
try
{
var pairs = System.Text.Json.JsonSerializer
.Deserialize<List<List<System.Text.Json.JsonElement>>>(json);
if (pairs == null)
{
error = "Bindings JSON must be a non-null array of [attributeName, dataConnectionId] pairs.";
return false;
}
var result = new List<(string, int)>(pairs.Count);
foreach (var pair in pairs)
{
if (pair.Count != 2)
{
error = "Each binding must be a [attributeName, dataConnectionId] pair of exactly two elements.";
return false;
}
if (pair[0].ValueKind != System.Text.Json.JsonValueKind.String)
{
error = "The first element of each binding (attributeName) must be a string.";
return false;
}
if (pair[1].ValueKind != System.Text.Json.JsonValueKind.Number
|| !pair[1].TryGetInt32(out var connectionId))
{
error = "The second element of each binding (dataConnectionId) must be an integer.";
return false;
}
result.Add((pair[0].GetString()!, connectionId));
}
bindings = result;
return true;
}
catch (System.Text.Json.JsonException ex)
{
error = $"Invalid bindings JSON: {ex.Message}";
return false;
}
}
/// <summary>
/// Parses the <c>--overrides</c> argument — a JSON object of
/// <c>attributeName -> value</c> pairs — into a typed dictionary. Returns
/// <c>false</c> with a descriptive <paramref name="error"/> instead of throwing
/// when the JSON is malformed or null.
/// </summary>
internal static bool TryParseOverrides(
string json,
out Dictionary<string, string?>? overrides,
out string? error)
{
overrides = null;
error = null;
try
{
var parsed = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, string?>>(json);
if (parsed == null)
{
error = "Overrides JSON must be a non-null object of attribute name -> value pairs.";
return false;
}
overrides = parsed;
return true;
}
catch (System.Text.Json.JsonException ex)
{
error = $"Invalid overrides JSON: {ex.Message}";
return false;
}
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
@@ -178,11 +267,14 @@ public static class InstanceCommands
{
var id = result.GetValue(idOption);
var overridesJson = result.GetValue(overridesOption)!;
var overrides = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string?>>(overridesJson)
?? throw new InvalidOperationException("Invalid overrides JSON");
if (!TryParseOverrides(overridesJson, out var overrides, out var error))
{
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceOverridesCommand(id, overrides));
new SetInstanceOverridesCommand(id, overrides!));
});
return cmd;
}

View File

@@ -59,6 +59,8 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
|----------|-------------|
| `SCADALINK_MANAGEMENT_URL` | Management API URL (overrides config file) |
| `SCADALINK_FORMAT` | Default output format (overrides config file) |
| `SCADALINK_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADALINK_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
## Output