Skip to content

Commit 857247c

Browse files
Add automation log inspection
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 94f64bf commit 857247c

File tree

6 files changed

+313
-1
lines changed

6 files changed

+313
-1
lines changed

cli-arguments.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
| `--automation set-setting --key key (--enabled true|false \| --value text)` | Sets a boolean or string setting through the automation service | 2026.1+ |
3131
| `--automation clear-setting --key key` | Clears a string-backed setting through the automation service | 2026.1+ |
3232
| `--automation reset-settings` | Resets non-secure settings while preserving the active automation session token | 2026.1+ |
33+
| `--automation get-app-log [--level n]` | Reads the UniGetUI application log as structured JSON, with optional severity filtering | 2026.1+ |
34+
| `--automation get-operation-history` | Reads the persisted operation history shown by the log/history UI surfaces | 2026.1+ |
35+
| `--automation get-manager-log [--manager name] [--verbose]` | Reads manager task logs, optionally for one manager and with verbose subprocess/stdin/stdout detail | 2026.1+ |
3336
| `--automation list-installed --manager name` | Lists installed packages for the selected manager through the automation service and returns structured JSON | 2026.1+ |
3437
| `--automation search-packages --manager name --query text [--max-results n]` | Searches packages through the automation service and returns structured JSON | 2026.1+ |
3538
| `--automation package-details --manager name --package-id id` | Fetches the package-details payload currently exposed through the automation layer | 2026.1+ |
@@ -62,7 +65,7 @@
6265

6366
- `dotnet src\UniGetUI.Avalonia\bin\Release\net10.0\UniGetUI.Avalonia.dll --headless` starts the local automation daemon without opening any window or requiring a graphical desktop session.
6467
- `dotnet src\UniGetUI.Cli\bin\Release\net10.0\UniGetUI.Cli.dll <command>` is the cross-platform CLI wrapper for the automation service. It automatically prepends `--automation`, so `UniGetUI.Cli status` and `UniGetUI.Cli search-packages --manager ".NET Tool" --query dotnetsay` work directly.
65-
- Current agent-oriented command coverage includes status/version, manager/source inspection, settings inspection and mutation, package search/details/version listing, ignored-update management, and package install/update/uninstall flows.
68+
- Current agent-oriented command coverage includes status/version, manager/source inspection, settings inspection and mutation, app/history/manager log inspection, package search/details/version listing, ignored-update management, and package install/update/uninstall flows.
6669

6770
<br><br>
6871
# `unigetui://` deep link

src/UniGetUI.Interface.BackgroundApi/AutomationCliCommandRunner.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,33 @@ await client.RemoveSourceAsync(BuildSourceRequest(args))
113113
output,
114114
await client.ResetSettingsAsync()
115115
),
116+
"get-app-log" => await WriteJsonAsync(
117+
output,
118+
new
119+
{
120+
status = "success",
121+
entries = await client.GetAppLogAsync(GetOptionalIntArgument(args, "--level") ?? 4),
122+
}
123+
),
124+
"get-operation-history" => await WriteJsonAsync(
125+
output,
126+
new
127+
{
128+
status = "success",
129+
history = await client.GetOperationHistoryAsync(),
130+
}
131+
),
132+
"get-manager-log" => await WriteJsonAsync(
133+
output,
134+
new
135+
{
136+
status = "success",
137+
managers = await client.GetManagerLogAsync(
138+
GetOptionalArgument(args, "--manager"),
139+
args.Contains("--verbose")
140+
),
141+
}
142+
),
116143
"get-version" => await WriteJsonAsync(
117144
output,
118145
new
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using UniGetUI.Core.Logging;
2+
using UniGetUI.Core.SettingsEngine;
3+
using UniGetUI.PackageEngine;
4+
using UniGetUI.PackageEngine.Interfaces;
5+
6+
namespace UniGetUI.Interface;
7+
8+
public sealed class AutomationAppLogEntry
9+
{
10+
public string Time { get; set; } = "";
11+
public string Severity { get; set; } = "";
12+
public string Content { get; set; } = "";
13+
}
14+
15+
public sealed class AutomationOperationHistoryEntry
16+
{
17+
public string Content { get; set; } = "";
18+
}
19+
20+
public sealed class AutomationManagerLogTask
21+
{
22+
public int Index { get; set; }
23+
public string[] Lines { get; set; } = [];
24+
}
25+
26+
public sealed class AutomationManagerLogInfo
27+
{
28+
public string Name { get; set; } = "";
29+
public string DisplayName { get; set; } = "";
30+
public string Version { get; set; } = "";
31+
public AutomationManagerLogTask[] Tasks { get; set; } = [];
32+
}
33+
34+
public static class AutomationLogsApi
35+
{
36+
public static IReadOnlyList<AutomationAppLogEntry> ListAppLog(int level = 4)
37+
{
38+
return Logger.GetLogs()
39+
.Where(entry => !string.IsNullOrWhiteSpace(entry.Content) && !ShouldSkip(entry.Severity, level))
40+
.Select(entry => new AutomationAppLogEntry
41+
{
42+
Time = entry.Time.ToString("O"),
43+
Severity = entry.Severity.ToString().ToLowerInvariant(),
44+
Content = entry.Content,
45+
})
46+
.ToArray();
47+
}
48+
49+
public static IReadOnlyList<AutomationOperationHistoryEntry> ListOperationHistory()
50+
{
51+
return Settings.GetValue(Settings.K.OperationHistory)
52+
.Split('\n')
53+
.Select(line => line.Replace("\r", "").Replace("\n", "").Trim())
54+
.Where(line => !string.IsNullOrWhiteSpace(line))
55+
.Select(line => new AutomationOperationHistoryEntry { Content = line })
56+
.ToArray();
57+
}
58+
59+
public static IReadOnlyList<AutomationManagerLogInfo> ListManagerLogs(
60+
string? managerName = null,
61+
bool verbose = false
62+
)
63+
{
64+
return ResolveManagers(managerName)
65+
.Select(manager => new AutomationManagerLogInfo
66+
{
67+
Name = manager.Name,
68+
DisplayName = manager.DisplayName,
69+
Version = manager.Status.Version,
70+
Tasks = manager.TaskLogger.Operations
71+
.Select((operation, index) => new AutomationManagerLogTask
72+
{
73+
Index = index,
74+
Lines = operation
75+
.AsColoredString(verbose)
76+
.Where(line => !string.IsNullOrWhiteSpace(line))
77+
.Select(StripColorCode)
78+
.Where(line => !string.IsNullOrWhiteSpace(line))
79+
.ToArray(),
80+
})
81+
.Where(task => task.Lines.Length > 0)
82+
.ToArray(),
83+
})
84+
.ToArray();
85+
}
86+
87+
private static IReadOnlyList<IPackageManager> ResolveManagers(string? managerName)
88+
{
89+
var managers = PEInterface.Managers
90+
.Where(manager =>
91+
string.IsNullOrWhiteSpace(managerName)
92+
|| manager.Name.Equals(managerName, StringComparison.OrdinalIgnoreCase)
93+
|| manager.DisplayName.Equals(managerName, StringComparison.OrdinalIgnoreCase)
94+
)
95+
.OrderBy(manager => manager.DisplayName, StringComparer.OrdinalIgnoreCase)
96+
.ToArray();
97+
98+
if (managers.Length == 0)
99+
{
100+
throw new InvalidOperationException(
101+
string.IsNullOrWhiteSpace(managerName)
102+
? "No package managers are available."
103+
: $"No package manager matching \"{managerName}\" was found."
104+
);
105+
}
106+
107+
return managers;
108+
}
109+
110+
private static bool ShouldSkip(LogEntry.SeverityLevel severity, int level) =>
111+
level switch
112+
{
113+
<= 1 => severity != LogEntry.SeverityLevel.Error,
114+
2 => severity is LogEntry.SeverityLevel.Debug
115+
or LogEntry.SeverityLevel.Info
116+
or LogEntry.SeverityLevel.Success,
117+
3 => severity is LogEntry.SeverityLevel.Debug or LogEntry.SeverityLevel.Info,
118+
4 => severity == LogEntry.SeverityLevel.Debug,
119+
_ => false,
120+
};
121+
122+
private static string StripColorCode(string line)
123+
{
124+
return line.Length > 1 && char.IsDigit(line[0]) ? line[1..] : line;
125+
}
126+
}

src/UniGetUI.Interface.BackgroundApi/BackgroundApi.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ public async Task Start()
7777
endpoints.MapPost("/v3/settings/set", V3_SetSetting);
7878
endpoints.MapPost("/v3/settings/clear", V3_ClearSetting);
7979
endpoints.MapPost("/v3/settings/reset", V3_ResetSettings);
80+
endpoints.MapGet("/v3/logs/app", V3_GetAppLog);
81+
endpoints.MapGet("/v3/logs/history", V3_GetOperationHistory);
82+
endpoints.MapGet("/v3/logs/manager", V3_GetManagerLog);
8083
endpoints.MapGet("/v3/packages/search", V3_SearchPackages);
8184
endpoints.MapGet("/v3/packages/installed", V3_ListInstalledPackages);
8285
endpoints.MapGet("/v3/packages/updates", V3_ListUpgradablePackages);
@@ -389,6 +392,74 @@ await context.Response.WriteAsJsonAsync(
389392
);
390393
}
391394

395+
private async Task V3_GetAppLog(HttpContext context)
396+
{
397+
if (!AuthenticateToken(context.Request.Query["token"]))
398+
{
399+
context.Response.StatusCode = 401;
400+
return;
401+
}
402+
403+
int level = int.TryParse(context.Request.Query["level"], out int parsedLevel)
404+
? parsedLevel
405+
: 4;
406+
await context.Response.WriteAsJsonAsync(
407+
AutomationLogsApi.ListAppLog(level),
408+
new JsonSerializerOptions(SerializationHelpers.DefaultOptions)
409+
{
410+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
411+
WriteIndented = true,
412+
}
413+
);
414+
}
415+
416+
private async Task V3_GetOperationHistory(HttpContext context)
417+
{
418+
if (!AuthenticateToken(context.Request.Query["token"]))
419+
{
420+
context.Response.StatusCode = 401;
421+
return;
422+
}
423+
424+
await context.Response.WriteAsJsonAsync(
425+
AutomationLogsApi.ListOperationHistory(),
426+
new JsonSerializerOptions(SerializationHelpers.DefaultOptions)
427+
{
428+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
429+
WriteIndented = true,
430+
}
431+
);
432+
}
433+
434+
private async Task V3_GetManagerLog(HttpContext context)
435+
{
436+
if (!AuthenticateToken(context.Request.Query["token"]))
437+
{
438+
context.Response.StatusCode = 401;
439+
return;
440+
}
441+
442+
try
443+
{
444+
await context.Response.WriteAsJsonAsync(
445+
AutomationLogsApi.ListManagerLogs(
446+
context.Request.Query["manager"],
447+
bool.TryParse(context.Request.Query["verbose"], out bool verbose) && verbose
448+
),
449+
new JsonSerializerOptions(SerializationHelpers.DefaultOptions)
450+
{
451+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
452+
WriteIndented = true,
453+
}
454+
);
455+
}
456+
catch (InvalidOperationException ex)
457+
{
458+
context.Response.StatusCode = 400;
459+
await context.Response.WriteAsync(ex.Message);
460+
}
461+
}
462+
392463
private async Task WIDGETS_V1_GetUniGetUIVersion(HttpContext context)
393464
{
394465
if (!AuthenticateToken(context.Request.Query["token"]))

src/UniGetUI.Interface.BackgroundApi/BackgroundApiClient.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,50 @@ public async Task<BackgroundApiCommandResult> ResetSettingsAsync()
155155
};
156156
}
157157

158+
public async Task<IReadOnlyList<AutomationAppLogEntry>> GetAppLogAsync(int level = 4)
159+
{
160+
return await ReadAuthenticatedJsonAsync<IReadOnlyList<AutomationAppLogEntry>>(
161+
HttpMethod.Get,
162+
"/v3/logs/app",
163+
new Dictionary<string, string> { ["level"] = level.ToString() }
164+
) ?? [];
165+
}
166+
167+
public async Task<IReadOnlyList<AutomationOperationHistoryEntry>> GetOperationHistoryAsync()
168+
{
169+
return await ReadAuthenticatedJsonAsync<IReadOnlyList<AutomationOperationHistoryEntry>>(
170+
HttpMethod.Get,
171+
"/v3/logs/history"
172+
) ?? [];
173+
}
174+
175+
public async Task<IReadOnlyList<AutomationManagerLogInfo>> GetManagerLogAsync(
176+
string? managerName = null,
177+
bool verbose = false
178+
)
179+
{
180+
Dictionary<string, string>? parameters = null;
181+
if (!string.IsNullOrWhiteSpace(managerName) || verbose)
182+
{
183+
parameters = new Dictionary<string, string>();
184+
if (!string.IsNullOrWhiteSpace(managerName))
185+
{
186+
parameters["manager"] = managerName;
187+
}
188+
189+
if (verbose)
190+
{
191+
parameters["verbose"] = "true";
192+
}
193+
}
194+
195+
return await ReadAuthenticatedJsonAsync<IReadOnlyList<AutomationManagerLogInfo>>(
196+
HttpMethod.Get,
197+
"/v3/logs/manager",
198+
parameters
199+
) ?? [];
200+
}
201+
158202
public async Task<BackgroundApiCommandResult> OpenWindowAsync()
159203
{
160204
await SendAuthenticatedGetAsync("/widgets/v1/open_wingetui");
@@ -600,6 +644,7 @@ private static TimeSpan GetRequestTimeout(HttpMethod method, string relativePath
600644
if (
601645
relativePath.StartsWith("/v3/managers", StringComparison.OrdinalIgnoreCase)
602646
|| relativePath.StartsWith("/v3/settings", StringComparison.OrdinalIgnoreCase)
647+
|| relativePath.StartsWith("/v3/logs/", StringComparison.OrdinalIgnoreCase)
603648
)
604649
{
605650
return TimeSpan.FromSeconds(15);

testing/automation/run-cli-e2e.ps1

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ New-Item -ItemType Directory -Path $daemonRoot | Out-Null
2929
$env:HOME = $daemonRoot
3030
$env:USERPROFILE = $daemonRoot
3131
$env:DOTNET_CLI_HOME = $daemonRoot
32+
$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'
33+
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
3234

3335
$transportArgs = @()
3436
$daemonArgs = @('run', '--project', $daemonProject, '--configuration', $configuration, '--no-build', '--', '--headless')
@@ -144,6 +146,14 @@ try {
144146
throw "list-settings did not report FreshValue"
145147
}
146148

149+
$appLog = Invoke-CliJson -Arguments @('get-app-log', '--level', '5')
150+
if (@($appLog.entries).Count -eq 0) {
151+
throw "get-app-log returned no entries"
152+
}
153+
if (@($appLog.entries | Where-Object { -not [string]::IsNullOrWhiteSpace($_.content) }).Count -eq 0) {
154+
throw "get-app-log did not return readable log content"
155+
}
156+
147157
$setFreshValue = Invoke-CliJson -Arguments @('set-setting', '--key', 'FreshValue', '--value', 'cli-smoke')
148158
if ($setFreshValue.setting.stringValue -ne 'cli-smoke') {
149159
throw "set-setting did not persist FreshValue"
@@ -250,6 +260,36 @@ try {
250260
}
251261
$remainingDotnetsay = @($installedAfterUninstall.packages | Where-Object { $_.id -eq 'dotnetsay' })
252262

263+
$operationHistory = Invoke-CliJson -Arguments @('get-operation-history')
264+
if ($null -eq $operationHistory.history) {
265+
throw "get-operation-history did not return a history payload"
266+
}
267+
if (
268+
@($operationHistory.history).Count -gt 0 -and
269+
@($operationHistory.history | Where-Object { -not [string]::IsNullOrWhiteSpace($_.content) }).Count -eq 0
270+
) {
271+
throw "get-operation-history returned entries without readable content"
272+
}
273+
274+
$managerLog = Wait-ForCliCondition `
275+
-Arguments @('get-manager-log', '--manager', '.NET Tool', '--verbose') `
276+
-FailureMessage 'get-manager-log did not capture .NET Tool task output for dotnetsay' `
277+
-Condition {
278+
param($response)
279+
@(
280+
$response.managers |
281+
Where-Object {
282+
$_.displayName -eq '.NET Tool' -and
283+
@(
284+
$_.tasks |
285+
Where-Object {
286+
@($_.lines | Where-Object { $_ -match 'dotnetsay' }).Count -gt 0
287+
}
288+
).Count -gt 0
289+
}
290+
).Count -gt 0
291+
}
292+
253293
$clearFreshValue = Invoke-CliJson -Arguments @('clear-setting', '--key', 'FreshValue')
254294
if ($clearFreshValue.setting.isSet) {
255295
throw "clear-setting did not clear FreshValue"

0 commit comments

Comments
 (0)