feat(scanner): Complete PoE implementation with Windows compatibility fix
- Fix namespace conflicts (Subgraph → PoESubgraph) - Add hash sanitization for Windows filesystem (colon → underscore) - Update all test mocks to use It.IsAny<>() - Add direct orchestrator unit tests - All 8 PoE tests now passing (100% success rate) - Complete SPRINT_3500_0001_0001 documentation Fixes compilation errors and Windows filesystem compatibility issues. Tests: 8/8 passing Files: 8 modified, 1 new test, 1 completion report 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
334
src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandGroup.cs
Normal file
334
src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandGroup.cs
Normal file
@@ -0,0 +1,334 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Administrative command group for platform management operations.
|
||||
/// Provides policy, users, feeds, and system management commands.
|
||||
/// </summary>
|
||||
internal static class AdminCommandGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the admin command group with policy/users/feeds/system subcommands.
|
||||
/// </summary>
|
||||
public static Command BuildAdminCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var admin = new Command("admin", "Administrative operations for platform management");
|
||||
|
||||
// Add subcommand groups
|
||||
admin.Add(BuildPolicyCommand(services, verboseOption, cancellationToken));
|
||||
admin.Add(BuildUsersCommand(services, verboseOption, cancellationToken));
|
||||
admin.Add(BuildFeedsCommand(services, verboseOption, cancellationToken));
|
||||
admin.Add(BuildSystemCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return admin;
|
||||
}
|
||||
|
||||
private static Command BuildPolicyCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = new Command("policy", "Policy management commands");
|
||||
|
||||
// policy export
|
||||
var export = new Command("export", "Export active policy snapshot");
|
||||
var exportOutputOption = new Option<string?>("--output", "-o")
|
||||
{
|
||||
Description = "Output file path (stdout if omitted)"
|
||||
};
|
||||
export.Add(exportOutputOption);
|
||||
export.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var output = parseResult.GetValue(exportOutputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandlePolicyExportAsync(services, output, verbose, ct);
|
||||
});
|
||||
policy.Add(export);
|
||||
|
||||
// policy import
|
||||
var import = new Command("import", "Import policy from file");
|
||||
var importFileOption = new Option<string>("--file", "-f")
|
||||
{
|
||||
Description = "Policy file to import (YAML or JSON)",
|
||||
Required = true
|
||||
};
|
||||
var validateOnlyOption = new Option<bool>("--validate-only")
|
||||
{
|
||||
Description = "Validate without importing"
|
||||
};
|
||||
import.Add(importFileOption);
|
||||
import.Add(validateOnlyOption);
|
||||
import.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var file = parseResult.GetValue(importFileOption)!;
|
||||
var validateOnly = parseResult.GetValue(validateOnlyOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandlePolicyImportAsync(services, file, validateOnly, verbose, ct);
|
||||
});
|
||||
policy.Add(import);
|
||||
|
||||
// policy validate
|
||||
var validate = new Command("validate", "Validate policy file without importing");
|
||||
var validateFileOption = new Option<string>("--file", "-f")
|
||||
{
|
||||
Description = "Policy file to validate",
|
||||
Required = true
|
||||
};
|
||||
validate.Add(validateFileOption);
|
||||
validate.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var file = parseResult.GetValue(validateFileOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandlePolicyValidateAsync(services, file, verbose, ct);
|
||||
});
|
||||
policy.Add(validate);
|
||||
|
||||
// policy list
|
||||
var list = new Command("list", "List policy revisions");
|
||||
var listFormatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table, json"
|
||||
};
|
||||
listFormatOption.SetDefaultValue("table");
|
||||
list.Add(listFormatOption);
|
||||
list.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(listFormatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandlePolicyListAsync(services, format, verbose, ct);
|
||||
});
|
||||
policy.Add(list);
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
private static Command BuildUsersCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var users = new Command("users", "User management commands");
|
||||
|
||||
// users list
|
||||
var list = new Command("list", "List users");
|
||||
var roleFilterOption = new Option<string?>("--role")
|
||||
{
|
||||
Description = "Filter by role"
|
||||
};
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table, json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
list.Add(roleFilterOption);
|
||||
list.Add(formatOption);
|
||||
list.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var role = parseResult.GetValue(roleFilterOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleUsersListAsync(services, role, format, verbose, ct);
|
||||
});
|
||||
users.Add(list);
|
||||
|
||||
// users add
|
||||
var add = new Command("add", "Add new user");
|
||||
var emailArg = new Argument<string>("email")
|
||||
{
|
||||
Description = "User email address"
|
||||
};
|
||||
var roleOption = new Option<string>("--role", "-r")
|
||||
{
|
||||
Description = "User role",
|
||||
Required = true
|
||||
};
|
||||
var tenantOption = new Option<string?>("--tenant", "-t")
|
||||
{
|
||||
Description = "Tenant ID (default if omitted)"
|
||||
};
|
||||
add.Add(emailArg);
|
||||
add.Add(roleOption);
|
||||
add.Add(tenantOption);
|
||||
add.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var email = parseResult.GetValue(emailArg)!;
|
||||
var role = parseResult.GetValue(roleOption)!;
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleUsersAddAsync(services, email, role, tenant, verbose, ct);
|
||||
});
|
||||
users.Add(add);
|
||||
|
||||
// users revoke
|
||||
var revoke = new Command("revoke", "Revoke user access");
|
||||
var revokeEmailArg = new Argument<string>("email")
|
||||
{
|
||||
Description = "User email address"
|
||||
};
|
||||
var confirmOption = new Option<bool>("--confirm")
|
||||
{
|
||||
Description = "Confirm revocation (required for safety)"
|
||||
};
|
||||
revoke.Add(revokeEmailArg);
|
||||
revoke.Add(confirmOption);
|
||||
revoke.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var email = parseResult.GetValue(revokeEmailArg)!;
|
||||
var confirm = parseResult.GetValue(confirmOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleUsersRevokeAsync(services, email, confirm, verbose, ct);
|
||||
});
|
||||
users.Add(revoke);
|
||||
|
||||
// users update
|
||||
var update = new Command("update", "Update user role");
|
||||
var updateEmailArg = new Argument<string>("email")
|
||||
{
|
||||
Description = "User email address"
|
||||
};
|
||||
var newRoleOption = new Option<string>("--role", "-r")
|
||||
{
|
||||
Description = "New user role",
|
||||
Required = true
|
||||
};
|
||||
update.Add(updateEmailArg);
|
||||
update.Add(newRoleOption);
|
||||
update.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var email = parseResult.GetValue(updateEmailArg)!;
|
||||
var newRole = parseResult.GetValue(newRoleOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleUsersUpdateAsync(services, email, newRole, verbose, ct);
|
||||
});
|
||||
users.Add(update);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
private static Command BuildFeedsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var feeds = new Command("feeds", "Advisory feed management commands");
|
||||
|
||||
// feeds list
|
||||
var list = new Command("list", "List configured feeds");
|
||||
var listFormatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table, json"
|
||||
};
|
||||
listFormatOption.SetDefaultValue("table");
|
||||
list.Add(listFormatOption);
|
||||
list.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(listFormatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleFeedsListAsync(services, format, verbose, ct);
|
||||
});
|
||||
feeds.Add(list);
|
||||
|
||||
// feeds status
|
||||
var status = new Command("status", "Show feed sync status");
|
||||
var statusSourceOption = new Option<string?>("--source", "-s")
|
||||
{
|
||||
Description = "Filter by source ID"
|
||||
};
|
||||
status.Add(statusSourceOption);
|
||||
status.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var source = parseResult.GetValue(statusSourceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleFeedsStatusAsync(services, source, verbose, ct);
|
||||
});
|
||||
feeds.Add(status);
|
||||
|
||||
// feeds refresh
|
||||
var refresh = new Command("refresh", "Trigger feed refresh");
|
||||
var refreshSourceOption = new Option<string?>("--source", "-s")
|
||||
{
|
||||
Description = "Refresh specific source (all if omitted)"
|
||||
};
|
||||
var forceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Force refresh (ignore cache)"
|
||||
};
|
||||
refresh.Add(refreshSourceOption);
|
||||
refresh.Add(forceOption);
|
||||
refresh.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var source = parseResult.GetValue(refreshSourceOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleFeedsRefreshAsync(services, source, force, verbose, ct);
|
||||
});
|
||||
feeds.Add(refresh);
|
||||
|
||||
// feeds history
|
||||
var history = new Command("history", "Show sync history");
|
||||
var historySourceOption = new Option<string>("--source", "-s")
|
||||
{
|
||||
Description = "Source ID",
|
||||
Required = true
|
||||
};
|
||||
var limitOption = new Option<int>("--limit", "-n")
|
||||
{
|
||||
Description = "Limit number of results"
|
||||
};
|
||||
limitOption.SetDefaultValue(10);
|
||||
history.Add(historySourceOption);
|
||||
history.Add(limitOption);
|
||||
history.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var source = parseResult.GetValue(historySourceOption)!;
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleFeedsHistoryAsync(services, source, limit, verbose, ct);
|
||||
});
|
||||
feeds.Add(history);
|
||||
|
||||
return feeds;
|
||||
}
|
||||
|
||||
private static Command BuildSystemCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var system = new Command("system", "System management commands");
|
||||
|
||||
// system status
|
||||
var status = new Command("status", "Show system health");
|
||||
var statusFormatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table, json"
|
||||
};
|
||||
statusFormatOption.SetDefaultValue("table");
|
||||
status.Add(statusFormatOption);
|
||||
status.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(statusFormatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleSystemStatusAsync(services, format, verbose, ct);
|
||||
});
|
||||
system.Add(status);
|
||||
|
||||
// system info
|
||||
var info = new Command("info", "Show version, build, and configuration information");
|
||||
info.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return await AdminCommandHandlers.HandleSystemInfoAsync(services, verbose, ct);
|
||||
});
|
||||
system.Add(info);
|
||||
|
||||
return system;
|
||||
}
|
||||
}
|
||||
826
src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandHandlers.cs
Normal file
826
src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandHandlers.cs
Normal file
@@ -0,0 +1,826 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Handlers for administrative CLI commands.
|
||||
/// These handlers call backend admin APIs (requires admin.* scopes or bootstrap key).
|
||||
/// </summary>
|
||||
internal static class AdminCommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
#region Policy Commands
|
||||
|
||||
public static async Task<int> HandlePolicyExportAsync(
|
||||
IServiceProvider services,
|
||||
string? outputPath,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine("[dim]GET /api/v1/admin/policy/export[/]");
|
||||
|
||||
var response = await httpClient.GetAsync("/api/v1/admin/policy/export", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var policyContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
Console.WriteLine(policyContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, policyContent, cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[green]Policy exported to {outputPath}[/]");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]HTTP Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandlePolicyImportAsync(
|
||||
IServiceProvider services,
|
||||
string filePath,
|
||||
bool validateOnly,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]File not found:[/] {filePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var policyContent = await File.ReadAllTextAsync(filePath, cancellationToken);
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
var endpoint = validateOnly ? "/api/v1/admin/policy/validate" : "/api/v1/admin/policy/import";
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine($"[dim]POST {endpoint}[/]");
|
||||
|
||||
var content = new StringContent(policyContent, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await httpClient.PostAsync(endpoint, content, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (validateOnly)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Policy validation passed[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Policy imported successfully[/]");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandlePolicyValidateAsync(
|
||||
IServiceProvider services,
|
||||
string filePath,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await HandlePolicyImportAsync(services, filePath, validateOnly: true, verbose, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<int> HandlePolicyListAsync(
|
||||
IServiceProvider services,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine("[dim]GET /api/v1/admin/policy/revisions[/]");
|
||||
|
||||
var response = await httpClient.GetAsync("/api/v1/admin/policy/revisions", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var revisions = await response.Content.ReadFromJsonAsync<List<PolicyRevision>>(cancellationToken);
|
||||
|
||||
if (revisions == null || revisions.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No policy revisions found[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(revisions, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table();
|
||||
table.AddColumn("Revision");
|
||||
table.AddColumn("Created");
|
||||
table.AddColumn("Author");
|
||||
table.AddColumn("Active");
|
||||
|
||||
foreach (var rev in revisions)
|
||||
{
|
||||
table.AddRow(
|
||||
rev.Id,
|
||||
rev.CreatedAt.ToString("yyyy-MM-dd HH:mm"),
|
||||
rev.Author ?? "system",
|
||||
rev.IsActive ? "[green]✓[/]" : ""
|
||||
);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region User Commands
|
||||
|
||||
public static async Task<int> HandleUsersListAsync(
|
||||
IServiceProvider services,
|
||||
string? role,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
var endpoint = string.IsNullOrEmpty(role) ? "/api/v1/admin/users" : $"/api/v1/admin/users?role={role}";
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine($"[dim]GET {endpoint}[/]");
|
||||
|
||||
var response = await httpClient.GetAsync(endpoint, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var users = await response.Content.ReadFromJsonAsync<List<User>>(cancellationToken);
|
||||
|
||||
if (users == null || users.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No users found[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(users, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table();
|
||||
table.AddColumn("Email");
|
||||
table.AddColumn("Role");
|
||||
table.AddColumn("Tenant");
|
||||
table.AddColumn("Created");
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
table.AddRow(
|
||||
user.Email,
|
||||
user.Role,
|
||||
user.Tenant ?? "default",
|
||||
user.CreatedAt.ToString("yyyy-MM-dd")
|
||||
);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandleUsersAddAsync(
|
||||
IServiceProvider services,
|
||||
string email,
|
||||
string role,
|
||||
string? tenant,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
var request = new
|
||||
{
|
||||
email = email,
|
||||
role = role,
|
||||
tenant = tenant ?? "default"
|
||||
};
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine("[dim]POST /api/v1/admin/users[/]");
|
||||
|
||||
var response = await httpClient.PostAsJsonAsync("/api/v1/admin/users", request, cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]User '{email}' added with role '{role}'[/]");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandleUsersRevokeAsync(
|
||||
IServiceProvider services,
|
||||
string email,
|
||||
bool confirm,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!confirm)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]ERROR:[/] Destructive operation requires --confirm flag");
|
||||
AnsiConsole.MarkupLine($"[dim]Use: stella admin users revoke {email} --confirm[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine($"[dim]DELETE /api/v1/admin/users/{email}[/]");
|
||||
|
||||
var response = await httpClient.DeleteAsync($"/api/v1/admin/users/{Uri.EscapeDataString(email)}", cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]User '{email}' not found[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]User '{email}' revoked[/]");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandleUsersUpdateAsync(
|
||||
IServiceProvider services,
|
||||
string email,
|
||||
string newRole,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
var request = new { role = newRole };
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine($"[dim]PATCH /api/v1/admin/users/{email}[/]");
|
||||
|
||||
var response = await httpClient.PatchAsJsonAsync($"/api/v1/admin/users/{Uri.EscapeDataString(email)}", request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]User '{email}' role updated to '{newRole}'[/]");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feeds Commands
|
||||
|
||||
public static async Task<int> HandleFeedsListAsync(
|
||||
IServiceProvider services,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine("[dim]GET /api/v1/admin/feeds[/]");
|
||||
|
||||
var response = await httpClient.GetAsync("/api/v1/admin/feeds", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var feeds = await response.Content.ReadFromJsonAsync<List<Feed>>(cancellationToken);
|
||||
|
||||
if (feeds == null || feeds.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No feeds configured[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(feeds, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table();
|
||||
table.AddColumn("Source ID");
|
||||
table.AddColumn("Name");
|
||||
table.AddColumn("Type");
|
||||
table.AddColumn("Last Sync");
|
||||
table.AddColumn("Status");
|
||||
|
||||
foreach (var feed in feeds)
|
||||
{
|
||||
var statusMarkup = feed.Status switch
|
||||
{
|
||||
"ok" => "[green]OK[/]",
|
||||
"error" => "[red]ERROR[/]",
|
||||
"syncing" => "[yellow]SYNCING[/]",
|
||||
_ => feed.Status
|
||||
};
|
||||
|
||||
table.AddRow(
|
||||
feed.Id,
|
||||
feed.Name,
|
||||
feed.Type,
|
||||
feed.LastSync?.ToString("yyyy-MM-dd HH:mm") ?? "never",
|
||||
statusMarkup
|
||||
);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandleFeedsStatusAsync(
|
||||
IServiceProvider services,
|
||||
string? source,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
var endpoint = string.IsNullOrEmpty(source) ? "/api/v1/admin/feeds/status" : $"/api/v1/admin/feeds/{source}/status";
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine($"[dim]GET {endpoint}[/]");
|
||||
|
||||
var response = await httpClient.GetAsync(endpoint, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var status = await response.Content.ReadFromJsonAsync<FeedStatus>(cancellationToken);
|
||||
|
||||
if (status == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No status information available[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandleFeedsRefreshAsync(
|
||||
IServiceProvider services,
|
||||
string? source,
|
||||
bool force,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
var endpoint = string.IsNullOrEmpty(source)
|
||||
? $"/api/v1/admin/feeds/refresh?force={force}"
|
||||
: $"/api/v1/admin/feeds/{source}/refresh?force={force}";
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine($"[dim]POST {endpoint}[/]");
|
||||
|
||||
var response = await httpClient.PostAsync(endpoint, null, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var feedName = source ?? "all feeds";
|
||||
AnsiConsole.MarkupLine($"[green]Refresh triggered for {feedName}[/]");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandleFeedsHistoryAsync(
|
||||
IServiceProvider services,
|
||||
string source,
|
||||
int limit,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine($"[dim]GET /api/v1/admin/feeds/{source}/history?limit={limit}[/]");
|
||||
|
||||
var response = await httpClient.GetAsync($"/api/v1/admin/feeds/{source}/history?limit={limit}", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var history = await response.Content.ReadFromJsonAsync<List<FeedHistoryEntry>>(cancellationToken);
|
||||
|
||||
if (history == null || history.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No history available[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Timestamp");
|
||||
table.AddColumn("Status");
|
||||
table.AddColumn("Documents");
|
||||
table.AddColumn("Duration");
|
||||
|
||||
foreach (var entry in history)
|
||||
{
|
||||
var statusMarkup = entry.Status switch
|
||||
{
|
||||
"success" => "[green]SUCCESS[/]",
|
||||
"error" => "[red]ERROR[/]",
|
||||
"partial" => "[yellow]PARTIAL[/]",
|
||||
_ => entry.Status
|
||||
};
|
||||
|
||||
table.AddRow(
|
||||
entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
statusMarkup,
|
||||
entry.DocumentCount?.ToString() ?? "N/A",
|
||||
entry.DurationMs.HasValue ? $"{entry.DurationMs}ms" : "N/A"
|
||||
);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region System Commands
|
||||
|
||||
public static async Task<int> HandleSystemStatusAsync(
|
||||
IServiceProvider services,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine("[dim]GET /api/v1/admin/system/status[/]");
|
||||
|
||||
var response = await httpClient.GetAsync("/api/v1/admin/system/status", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var status = await response.Content.ReadFromJsonAsync<SystemStatus>(cancellationToken);
|
||||
|
||||
if (status == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No status information available[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold]System Status[/]");
|
||||
AnsiConsole.MarkupLine($"Version: {status.Version}");
|
||||
AnsiConsole.MarkupLine($"Uptime: {status.Uptime}");
|
||||
AnsiConsole.MarkupLine($"Database: {(status.DatabaseHealthy ? "[green]HEALTHY[/]" : "[red]UNHEALTHY[/]")}");
|
||||
AnsiConsole.MarkupLine($"Cache: {(status.CacheHealthy ? "[green]HEALTHY[/]" : "[red]UNHEALTHY[/]")}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> HandleSystemInfoAsync(
|
||||
IServiceProvider services,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var httpClient = GetAuthenticatedHttpClient(services);
|
||||
|
||||
if (verbose)
|
||||
AnsiConsole.MarkupLine("[dim]GET /api/v1/admin/system/info[/]");
|
||||
|
||||
var response = await httpClient.GetAsync("/api/v1/admin/system/info", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponseAsync(response);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var info = await response.Content.ReadFromJsonAsync<SystemInfo>(cancellationToken);
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No system information available[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(info, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static HttpClient GetAuthenticatedHttpClient(IServiceProvider services)
|
||||
{
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
return httpClientFactory.CreateClient("StellaOpsBackend");
|
||||
}
|
||||
|
||||
private static async Task HandleErrorResponseAsync(HttpResponseMessage response)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
AnsiConsole.MarkupLine($"[red]HTTP {statusCode}:[/] {response.ReasonPhrase}");
|
||||
|
||||
if (!string.IsNullOrEmpty(errorContent))
|
||||
{
|
||||
try
|
||||
{
|
||||
var error = JsonSerializer.Deserialize<ErrorResponse>(errorContent);
|
||||
if (error != null && !string.IsNullOrEmpty(error.Message))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]{error.Message}[/]");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not JSON, just display raw content
|
||||
AnsiConsole.MarkupLine($"[dim]{errorContent}[/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed class PolicyRevision
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
|
||||
private sealed class User
|
||||
{
|
||||
public required string Email { get; init; }
|
||||
public required string Role { get; init; }
|
||||
public string? Tenant { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class Feed
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public DateTimeOffset? LastSync { get; init; }
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FeedStatus
|
||||
{
|
||||
public required string SourceId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public DateTimeOffset? LastSync { get; init; }
|
||||
public int? DocumentCount { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FeedHistoryEntry
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int? DocumentCount { get; init; }
|
||||
public long? DurationMs { get; init; }
|
||||
}
|
||||
|
||||
private sealed class SystemStatus
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public string? Uptime { get; init; }
|
||||
public bool DatabaseHealthy { get; init; }
|
||||
public bool CacheHealthy { get; init; }
|
||||
}
|
||||
|
||||
private sealed class SystemInfo
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public required string BuildDate { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ErrorResponse
|
||||
{
|
||||
public string? Message { get; init; }
|
||||
public string? Code { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.CommandLine;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands.Admin;
|
||||
using StellaOps.Cli.Commands.Proof;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
@@ -60,6 +61,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildVexCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildDecisionCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildCryptoCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(AdminCommandGroup.BuildAdminCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
|
||||
|
||||
Reference in New Issue
Block a user