feat(dotnet): add galaxy-browse CLI (§4.6); chore: verify version subcommand (§4.4)

This commit is contained in:
Joseph Doherty
2026-06-15 10:07:24 -04:00
parent 39ec2a3275
commit d7e2a8b3cf
5 changed files with 469 additions and 0 deletions
@@ -105,4 +105,16 @@ public interface IMxGatewayCliClient : IAsyncDisposable
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Fetches one page of direct children of a Galaxy parent (or the root
/// objects when the parent selector is unset), the primitive that backs the
/// lazy-browse helper.
/// </summary>
/// <param name="request">The browse-children request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The browse-children reply.</returns>
Task<BrowseChildrenReply> GalaxyBrowseChildrenAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken);
}
@@ -100,6 +100,14 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<BrowseChildrenReply> GalaxyBrowseChildrenAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.BrowseChildrenRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
@@ -144,6 +144,8 @@ public static class MxGatewayClientCli
.ConfigureAwait(false),
"galaxy-discover" => await GalaxyDiscoverAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-browse" => await GalaxyBrowseAsync(arguments, client, standardOutput, standardError, cancellation.Token)
.ConfigureAwait(false),
"galaxy-watch" => await GalaxyWatchAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
_ => WriteUnknownCommand(command, standardError),
@@ -1607,6 +1609,270 @@ public static class MxGatewayClientCli
return aggregate;
}
/// <summary>
/// Per-request page size for the galaxy-browse single-level walks. Mirrors
/// the library's <c>BrowseChildrenPageSize</c> so the CLI and the
/// lazy-browse helper page identically.
/// </summary>
private const int BrowseChildrenCliPageSize = 500;
/// <summary>
/// Drives the lazy-browse Galaxy surface from the CLI. Without
/// <c>--parent</c> it walks the root objects and eagerly expands
/// <c>--depth</c> further levels (each level reuses the same
/// <see cref="BrowseChildrenOptions"/>, like the library helper). With
/// <c>--parent</c> it fetches exactly one level of children for that
/// gobject id via a parent-scoped BrowseChildren request; <c>--depth</c>
/// is not meaningful there and a warning is emitted if combined, mirroring
/// the Go/Rust CLIs.
/// </summary>
private static async Task<int> GalaxyBrowseAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
TextWriter standardError,
CancellationToken cancellationToken)
{
BrowseChildrenOptions options = ParseBrowseChildrenOptions(arguments);
bool json = arguments.HasFlag("json");
int parent = arguments.GetInt32("parent", -1);
int depth = arguments.GetInt32("depth", 0);
// A specific parent → one level of children via the parent-scoped RPC.
if (parent >= 0)
{
if (depth > 0)
{
standardError.WriteLine("warning: --depth is ignored when --parent is specified.");
}
IReadOnlyList<GalaxyObject> children = await BrowseOneLevelAsync(
client,
options,
parent,
cancellationToken)
.ConfigureAwait(false);
if (json)
{
output.WriteLine(JsonSerializer.Serialize(
new
{
command = "galaxy-browse",
parentId = parent,
children = children.Select(GalaxyObjectToJsonElement).ToArray(),
},
JsonOptions));
return 0;
}
output.WriteLine(children.Count.ToString(CultureInfo.InvariantCulture));
foreach (GalaxyObject child in children)
{
output.WriteLine(FormatGalaxyObject(child, level: 0, hasChildrenHint: null));
}
return 0;
}
// No parent → walk the root objects, eagerly expanding --depth levels.
IReadOnlyList<BrowseTreeNode> roots = await BrowseTreeAsync(
client,
options,
parentGobjectId: 0,
remainingDepth: depth,
cancellationToken)
.ConfigureAwait(false);
if (json)
{
output.WriteLine(JsonSerializer.Serialize(
new
{
command = "galaxy-browse",
nodes = roots.Select(BrowseTreeNodeToJson).ToArray(),
},
JsonOptions));
return 0;
}
output.WriteLine(roots.Count.ToString(CultureInfo.InvariantCulture));
foreach (BrowseTreeNode node in roots)
{
WriteBrowseTreeNode(output, node, level: 0);
}
return 0;
}
/// <summary>
/// One node in the eagerly-expanded galaxy-browse tree: the Galaxy object,
/// the server's has-children hint, and any children fetched up to the
/// requested depth.
/// </summary>
private sealed record BrowseTreeNode(
GalaxyObject Object,
bool HasChildrenHint,
IReadOnlyList<BrowseTreeNode> Children);
/// <summary>
/// Fetches the direct children of <paramref name="parentGobjectId"/>
/// (0 = root) and recursively expands <paramref name="remainingDepth"/>
/// further levels. Paging is followed to completion at each level.
/// </summary>
private static async Task<IReadOnlyList<BrowseTreeNode>> BrowseTreeAsync(
IMxGatewayCliClient client,
BrowseChildrenOptions options,
int parentGobjectId,
int remainingDepth,
CancellationToken cancellationToken)
{
List<BrowseTreeNode> nodes = [];
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = BuildBrowseChildrenRequest(options, parentGobjectId, pageToken);
BrowseChildrenReply reply = await client.GalaxyBrowseChildrenAsync(request, cancellationToken)
.ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
GalaxyObject child = reply.Children[i];
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
IReadOnlyList<BrowseTreeNode> grandChildren = remainingDepth > 0
? await BrowseTreeAsync(client, options, child.GobjectId, remainingDepth - 1, cancellationToken)
.ConfigureAwait(false)
: [];
nodes.Add(new BrowseTreeNode(child, hint, grandChildren));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return nodes;
}
/// <summary>Fetches exactly one level of children for a parent gobject id, paging to completion.</summary>
private static async Task<IReadOnlyList<GalaxyObject>> BrowseOneLevelAsync(
IMxGatewayCliClient client,
BrowseChildrenOptions options,
int parentGobjectId,
CancellationToken cancellationToken)
{
List<GalaxyObject> children = [];
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = BuildBrowseChildrenRequest(options, parentGobjectId, pageToken);
BrowseChildrenReply reply = await client.GalaxyBrowseChildrenAsync(request, cancellationToken)
.ConfigureAwait(false);
children.AddRange(reply.Children);
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return children;
}
private static BrowseChildrenOptions ParseBrowseChildrenOptions(CliArguments arguments)
{
return new BrowseChildrenOptions
{
CategoryIds = ParseOptionalInt32List(arguments.GetOptional("category-ids")),
TemplateChainContains = ParseOptionalStringList(arguments.GetOptional("template-contains")),
TagNameGlob = arguments.GetOptional("tag-name-glob"),
AlarmBearingOnly = arguments.HasFlag("alarm-bearing-only"),
HistorizedOnly = arguments.HasFlag("historized-only"),
// Tri-state: only override the server default when the flag is present.
IncludeAttributes = arguments.HasFlag("include-attributes") ? true : null,
};
}
private static BrowseChildrenRequest BuildBrowseChildrenRequest(
BrowseChildrenOptions options,
int parentGobjectId,
string pageToken)
{
BrowseChildrenRequest request = new()
{
PageSize = BrowseChildrenCliPageSize,
PageToken = pageToken,
ParentGobjectId = parentGobjectId,
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
private static void WriteBrowseTreeNode(TextWriter output, BrowseTreeNode node, int level)
{
output.WriteLine(FormatGalaxyObject(node.Object, level, node.HasChildrenHint));
foreach (BrowseTreeNode child in node.Children)
{
WriteBrowseTreeNode(output, child, level + 1);
}
}
private static string FormatGalaxyObject(GalaxyObject galaxyObject, int level, bool? hasChildrenHint)
{
string indent = new(' ', level * 2);
string suffix = hasChildrenHint is null
? $"(attrs={galaxyObject.Attributes.Count})"
: $"(attrs={galaxyObject.Attributes.Count}, hasChildren={hasChildrenHint.Value})";
return $"{indent}{galaxyObject.GobjectId}\t{galaxyObject.TagName}\t{galaxyObject.BrowseName}\t{suffix}";
}
private static object BrowseTreeNodeToJson(BrowseTreeNode node)
{
return new
{
@object = GalaxyObjectToJsonElement(node.Object),
hasChildren = node.HasChildrenHint,
children = node.Children.Select(BrowseTreeNodeToJson).ToArray(),
};
}
private static JsonElement GalaxyObjectToJsonElement(GalaxyObject galaxyObject)
{
return JsonDocument.Parse(ProtobufJsonFormatter.Format(galaxyObject)).RootElement.Clone();
}
private static IReadOnlyList<int> ParseOptionalInt32List(string? value)
{
return string.IsNullOrWhiteSpace(value) ? [] : ParseInt32List(value);
}
private static IReadOnlyList<string> ParseOptionalStringList(string? value)
{
return string.IsNullOrWhiteSpace(value) ? [] : ParseStringList(value);
}
private static async Task<int> GalaxyWatchAsync(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -1736,6 +2002,7 @@ public static class MxGatewayClientCli
or "galaxy-test-connection"
or "galaxy-last-deploy"
or "galaxy-discover"
or "galaxy-browse"
or "galaxy-watch";
}
@@ -1797,6 +2064,7 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet galaxy-test-connection [--json]");
writer.WriteLine("mxgw-dotnet galaxy-last-deploy [--json]");
writer.WriteLine("mxgw-dotnet galaxy-discover [--json]");
writer.WriteLine("mxgw-dotnet galaxy-browse [--parent <gobject-id>] [--depth <n>] [--category-ids <n,n>] [--template-contains <s,s>] [--tag-name-glob <glob>] [--alarm-bearing-only] [--historized-only] [--include-attributes] [--json]");
writer.WriteLine("mxgw-dotnet galaxy-watch [--last-seen-deploy-time <iso8601>] [--max-events <n>] [--json]");
}
}