Skip to content

Commit fd52a38

Browse files
authored
Merge pull request #13 from Mythetech/feat/secret-manager-initial-immplementation
feat: secrets framework
2 parents 504be64 + f2dcc0e commit fd52a38

25 files changed

+1404
-6
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Mythetech.Framework.Desktop.Secrets;
3+
using Mythetech.Framework.Infrastructure.Secrets;
4+
5+
namespace Mythetech.Framework.Desktop;
6+
7+
/// <summary>
8+
/// Desktop-specific secret manager registration extensions
9+
/// </summary>
10+
public static class DesktopSecretManagerRegistrationExtensions
11+
{
12+
/// <summary>
13+
/// Registers 1Password CLI secret manager for desktop applications
14+
/// </summary>
15+
public static IServiceCollection AddOnePasswordSecretManager(this IServiceCollection services)
16+
{
17+
services.AddSecretManager<OnePasswordCliSecretManager>();
18+
return services;
19+
}
20+
}
21+

Mythetech.Framework.Desktop/Mythetech.Framework.Desktop.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<PackageId>Mythetech.Framework.Desktop</PackageId>
8-
<Version>0.2.0</Version>
8+
<Version>0.2.1</Version>
99
<Authors>Mythetech</Authors>
1010
<Description>Desktop-specific components for cross platform blazor applications</Description>
1111
<PackageTags>blazor;desktop;photino;components;ui</PackageTags>
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
using System.Diagnostics;
2+
using System.Text;
3+
using System.Text.Json;
4+
using Mythetech.Framework.Infrastructure.Secrets;
5+
6+
namespace Mythetech.Framework.Desktop.Secrets;
7+
8+
/// <summary>
9+
/// 1Password CLI implementation of ISecretManager
10+
/// </summary>
11+
public class OnePasswordCliSecretManager : ISecretManager
12+
{
13+
private const string OpCommand = "op";
14+
15+
/// <inheritdoc />
16+
public async Task<IEnumerable<Secret>> ListSecretsAsync(CancellationToken cancellationToken = default)
17+
{
18+
try
19+
{
20+
var result = await ExecuteOpCommandAsync(["item", "list", "--format", "json"], cancellationToken);
21+
if (string.IsNullOrWhiteSpace(result))
22+
{
23+
return [];
24+
}
25+
26+
return ParseItemList(result);
27+
}
28+
catch
29+
{
30+
return [];
31+
}
32+
}
33+
34+
/// <inheritdoc />
35+
public async Task<Secret?> GetSecretAsync(string key, CancellationToken cancellationToken = default)
36+
{
37+
ArgumentException.ThrowIfNullOrWhiteSpace(key);
38+
39+
try
40+
{
41+
var result = await ExecuteOpCommandAsync(["item", "get", key, "--format", "json"], cancellationToken);
42+
if (string.IsNullOrWhiteSpace(result))
43+
{
44+
return null;
45+
}
46+
47+
return ParseItem(result);
48+
}
49+
catch
50+
{
51+
return null;
52+
}
53+
}
54+
55+
/// <inheritdoc />
56+
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
57+
{
58+
try
59+
{
60+
var result = await ExecuteOpCommandAsync(["account", "list"], cancellationToken);
61+
return !string.IsNullOrWhiteSpace(result);
62+
}
63+
catch
64+
{
65+
try
66+
{
67+
var result = await ExecuteOpCommandAsync(["whoami"], cancellationToken);
68+
return !string.IsNullOrWhiteSpace(result);
69+
}
70+
catch
71+
{
72+
return false;
73+
}
74+
}
75+
}
76+
77+
/// <inheritdoc />
78+
public async Task<IEnumerable<Secret>> SearchSecretsAsync(string searchTerm, CancellationToken cancellationToken = default)
79+
{
80+
ArgumentException.ThrowIfNullOrWhiteSpace(searchTerm);
81+
82+
try
83+
{
84+
var allSecrets = await ListSecretsAsync(cancellationToken);
85+
var term = searchTerm.ToLowerInvariant();
86+
87+
return allSecrets.Where(s =>
88+
s.Key.Contains(term, StringComparison.OrdinalIgnoreCase) ||
89+
(s.Name?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) ||
90+
(s.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) ||
91+
(s.Tags?.Any(t => t.Contains(term, StringComparison.OrdinalIgnoreCase)) ?? false)
92+
);
93+
}
94+
catch
95+
{
96+
return [];
97+
}
98+
}
99+
100+
private async Task<string> ExecuteOpCommandAsync(string[] arguments, CancellationToken cancellationToken)
101+
{
102+
var startInfo = new ProcessStartInfo
103+
{
104+
FileName = OpCommand,
105+
Arguments = string.Join(" ", arguments.Select(arg => $"\"{arg}\"")),
106+
RedirectStandardOutput = true,
107+
RedirectStandardError = true,
108+
UseShellExecute = false,
109+
CreateNoWindow = true
110+
};
111+
112+
using var process = new Process { StartInfo = startInfo };
113+
114+
var outputBuilder = new StringBuilder();
115+
var errorBuilder = new StringBuilder();
116+
117+
process.OutputDataReceived += (_, e) =>
118+
{
119+
if (e.Data != null)
120+
{
121+
outputBuilder.AppendLine(e.Data);
122+
}
123+
};
124+
125+
process.ErrorDataReceived += (_, e) =>
126+
{
127+
if (e.Data != null)
128+
{
129+
errorBuilder.AppendLine(e.Data);
130+
}
131+
};
132+
133+
process.Start();
134+
process.BeginOutputReadLine();
135+
process.BeginErrorReadLine();
136+
137+
try
138+
{
139+
await process.WaitForExitAsync(cancellationToken);
140+
}
141+
catch (OperationCanceledException)
142+
{
143+
try
144+
{
145+
if (!process.HasExited)
146+
{
147+
process.Kill();
148+
}
149+
}
150+
catch
151+
{
152+
// Ignore errors when killing the process
153+
}
154+
throw;
155+
}
156+
157+
if (process.ExitCode != 0)
158+
{
159+
throw new InvalidOperationException($"1Password CLI command failed with exit code {process.ExitCode}: {errorBuilder}");
160+
}
161+
162+
return outputBuilder.ToString().Trim();
163+
}
164+
165+
private IEnumerable<Secret> ParseItemList(string json)
166+
{
167+
try
168+
{
169+
using var doc = JsonDocument.Parse(json);
170+
var items = new List<Secret>();
171+
172+
if (doc.RootElement.ValueKind == JsonValueKind.Array)
173+
{
174+
foreach (var element in doc.RootElement.EnumerateArray())
175+
{
176+
var secret = ParseItemElement(element);
177+
if (secret != null)
178+
{
179+
items.Add(secret);
180+
}
181+
}
182+
}
183+
184+
return items;
185+
}
186+
catch
187+
{
188+
return [];
189+
}
190+
}
191+
192+
private Secret? ParseItem(string json)
193+
{
194+
try
195+
{
196+
using var doc = JsonDocument.Parse(json);
197+
return ParseItemElement(doc.RootElement);
198+
}
199+
catch
200+
{
201+
return null;
202+
}
203+
}
204+
205+
private Secret? ParseItemElement(JsonElement element)
206+
{
207+
try
208+
{
209+
var id = element.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
210+
var title = element.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null;
211+
var overview = element.TryGetProperty("overview", out var overviewProp) ? overviewProp : default;
212+
var fields = element.TryGetProperty("fields", out var fieldsProp) ? fieldsProp : default;
213+
214+
if (string.IsNullOrWhiteSpace(id))
215+
{
216+
return null;
217+
}
218+
219+
var name = title ?? id;
220+
var description = overview.TryGetProperty("notesPlain", out var notesProp) ? notesProp.GetString() : null;
221+
222+
var tags = new List<string>();
223+
if (overview.ValueKind == JsonValueKind.Object && overview.TryGetProperty("tags", out var tagsProp))
224+
{
225+
foreach (var tag in tagsProp.EnumerateArray())
226+
{
227+
if (tag.ValueKind == JsonValueKind.String)
228+
{
229+
tags.Add(tag.GetString() ?? string.Empty);
230+
}
231+
}
232+
}
233+
234+
var category = overview.TryGetProperty("category", out var categoryProp) ? categoryProp.GetString() : null;
235+
236+
var value = ExtractPasswordValue(fields);
237+
if (string.IsNullOrWhiteSpace(value))
238+
{
239+
value = ExtractFirstFieldValue(fields);
240+
}
241+
242+
return new Secret
243+
{
244+
Key = id,
245+
Value = value ?? string.Empty,
246+
Name = name,
247+
Description = description,
248+
Tags = tags.Count > 0 ? tags.ToArray() : null,
249+
Category = category
250+
};
251+
}
252+
catch
253+
{
254+
return null;
255+
}
256+
}
257+
258+
private string? ExtractPasswordValue(JsonElement fields)
259+
{
260+
if (fields.ValueKind != JsonValueKind.Array) return null;
261+
262+
foreach (var field in fields.EnumerateArray())
263+
{
264+
if (field.TryGetProperty("id", out var idProp) &&
265+
idProp.GetString() == "password" &&
266+
field.TryGetProperty("value", out var valueProp))
267+
{
268+
return valueProp.GetString();
269+
}
270+
}
271+
272+
return null;
273+
}
274+
275+
private string? ExtractFirstFieldValue(JsonElement fields)
276+
{
277+
if (fields.ValueKind != JsonValueKind.Array) return null;
278+
279+
foreach (var field in fields.EnumerateArray())
280+
{
281+
if (field.TryGetProperty("value", out var valueProp))
282+
{
283+
var value = valueProp.GetString();
284+
if (!string.IsNullOrWhiteSpace(value))
285+
{
286+
return value;
287+
}
288+
}
289+
}
290+
291+
return null;
292+
}
293+
}
294+

Mythetech.Framework.Storybook/Stories/StorybookPluginData.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public static class StorybookPluginData
2121

2222
private static readonly Assembly _assembly = typeof(StorybookPluginData).Assembly;
2323

24+
/// <summary>
25+
/// Enabled plugin info
26+
/// </summary>
2427
public static readonly PluginInfo EnabledPluginInfo = new PluginInfo
2528
{
2629
Manifest = _manifest,
@@ -29,6 +32,9 @@ public static class StorybookPluginData
2932
LoadedAt = DateTime.UtcNow.AddDays(-5)
3033
};
3134

35+
/// <summary>
36+
/// Disabled plugin ino
37+
/// </summary>
3238
public static readonly PluginInfo DisabledPluginInfo = new PluginInfo
3339
{
3440
Manifest = _manifest,
@@ -37,6 +43,9 @@ public static class StorybookPluginData
3743
LoadedAt = DateTime.UtcNow.AddDays(-5)
3844
};
3945

46+
/// <summary>
47+
/// Deleted plugin info
48+
/// </summary>
4049
public static readonly PluginInfo DeletedPluginInfo = new PluginInfo
4150
{
4251
Manifest = _manifest,
@@ -45,6 +54,9 @@ public static class StorybookPluginData
4554
LoadedAt = DateTime.UtcNow.AddDays(-5)
4655
};
4756

57+
/// <summary>
58+
/// Component metadata
59+
/// </summary>
4860
public static readonly PluginComponentMetadata ComponentMetadata = new PluginComponentMetadata
4961
{
5062
ComponentType = typeof(StorybookPluginData),
@@ -68,10 +80,22 @@ private class FakePluginManifest : IPluginManifest
6880
}
6981
}
7082

83+
/// <summary>
84+
/// Type of plugin scenario
85+
/// </summary>
7186
public enum PluginScenario
7287
{
88+
/// <summary>
89+
/// Enabled plugin
90+
/// </summary>
7391
Enabled,
92+
/// <summary>
93+
/// Disabled plugin
94+
/// </summary>
7495
Disabled,
96+
/// <summary>
97+
/// Deleted plugin
98+
/// </summary>
7599
Deleted
76100
}
77101

0 commit comments

Comments
 (0)