Skip to content

Commit a7d2d88

Browse files
committed
feat(cli): implement CLI with DI and fetch command
1 parent 64cab3e commit a7d2d88

File tree

9 files changed

+319
-2
lines changed

9 files changed

+319
-2
lines changed

Proxxi.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<File Path="README.md" />
1010
</Folder>
1111
<Folder Name="/src/">
12+
<Project Path="src/Proxxi.Cli/Proxxi.Cli.csproj" />
1213
<Project Path="src/Proxxi.Core/Proxxi.Core.csproj" />
1314
<Project Path="src/Proxxi.Plugin.Loader/Proxxi.Plugin.Loader.csproj" />
1415
</Folder>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using Microsoft.Extensions.Options;
2+
3+
using Proxxi.Core.Models;
4+
using Proxxi.Core.Options;
5+
using Proxxi.Core.Providers;
6+
using Proxxi.Core.ProxyWriters;
7+
using Proxxi.Plugin.Loader.Extensions;
8+
using Proxxi.Plugin.Loader.PluginLoaders;
9+
using Proxxi.Plugin.Sdk.ProxySources;
10+
11+
using Spectre.Console;
12+
using Spectre.Console.Cli;
13+
14+
namespace Proxxi.Cli.Commands.Fetch;
15+
16+
public sealed class FetchCommand(
17+
IAnsiConsole console,
18+
IPluginConfigProvider configProvider,
19+
IPluginLoader pluginLoader,
20+
IOptions<ProxxiPathsOptions> options
21+
)
22+
: AsyncCommand<FetchCommandSettings>
23+
{
24+
private readonly ProxxiPathsOptions _pathOptions = options.Value;
25+
26+
public override async Task<int> ExecuteAsync(CommandContext context, FetchCommandSettings settings,
27+
CancellationToken ct)
28+
{
29+
Stream stream;
30+
OutputFormat format;
31+
32+
if (settings.Output != null)
33+
{
34+
stream = File.OpenWrite(settings.Output);
35+
36+
if (!Enum.TryParse(Path.GetExtension(settings.Output).TrimStart('.'), true, out format))
37+
format = settings.Format;
38+
}
39+
else
40+
{
41+
stream = Console.OpenStandardOutput();
42+
format = settings.Format;
43+
}
44+
45+
try
46+
{
47+
(IBatchProxySource? batchProxySource, IStreamProxySource? streamProxySource) =
48+
await GetPluginInstance(settings.Id, ct);
49+
50+
var writer = CreateProxyWriter(stream, format, settings.Pretty);
51+
52+
if (settings.Stream)
53+
{
54+
if (writer is not IStreamProxyWriter streamProxyWriter)
55+
throw new InvalidOperationException($"{format} output does not support stream mode.");
56+
57+
await FetchAndWriteProxiesAsync(streamProxySource, streamProxyWriter, ct);
58+
}
59+
else
60+
{
61+
if (writer is not IBatchProxyWriter batchProxyWriter)
62+
throw new InvalidOperationException($"{format} output does not support stream mode.");
63+
64+
await FetchAndWriteProxiesAsync(batchProxySource, batchProxyWriter, ct);
65+
}
66+
67+
return 0;
68+
}
69+
catch (OperationCanceledException)
70+
{
71+
console.MarkupLine("[yellow]Operation canceled.[/]");
72+
return 130;
73+
}
74+
finally
75+
{
76+
if (settings.Output != null)
77+
await stream.DisposeAsync();
78+
}
79+
}
80+
81+
private async Task<(IBatchProxySource?, IStreamProxySource?)> GetPluginInstance(string id, CancellationToken ct)
82+
{
83+
var pluginConfig = configProvider.Get(id);
84+
85+
if (pluginConfig == null)
86+
throw new InvalidOperationException($"Plugin '{id}' is not installed.");
87+
88+
if (!pluginConfig.Enabled)
89+
throw new InvalidOperationException($"Plugin '{id}' is disabled.");
90+
91+
var path = Path.Combine(_pathOptions.PluginsDir, pluginConfig.Path);
92+
93+
var plugin = pluginLoader.LoadPlugins([path])
94+
.FirstOrDefault(pd => StringComparer.OrdinalIgnoreCase.Equals(pd.Id, pluginConfig.Id));
95+
96+
if (plugin == null)
97+
throw new InvalidOperationException($"Plugin '{id}' is not loaded.");
98+
99+
return await plugin.CreateAsync(pluginConfig.Parameters.ToDictionary(), ct);
100+
}
101+
102+
private static async Task FetchAndWriteProxiesAsync(IBatchProxySource? batchProxySource,
103+
IBatchProxyWriter writer, CancellationToken ct)
104+
{
105+
if (batchProxySource == null)
106+
throw new InvalidOperationException("The plugin does not support batch mode.");
107+
108+
var proxies = await batchProxySource.FetchAsync(ct);
109+
110+
await writer.WriteAsync(proxies, ct);
111+
}
112+
113+
private static async Task FetchAndWriteProxiesAsync(IStreamProxySource? streamProxySource,
114+
IStreamProxyWriter writer, CancellationToken ct)
115+
{
116+
if (streamProxySource == null)
117+
throw new InvalidOperationException("The plugin does not support stream mode.");
118+
119+
var proxies = streamProxySource.FetchAsync(ct);
120+
121+
await writer.WriteAsync(proxies, ct);
122+
}
123+
124+
private static IProxyWriter CreateProxyWriter(Stream stream, OutputFormat format, bool isPretty) =>
125+
format switch
126+
{
127+
OutputFormat.Plain => new PlainProxyWriter(stream),
128+
OutputFormat.Url => new UrlProxyWriter(stream),
129+
OutputFormat.Json => new JsonProxyWriter(stream, isPretty),
130+
OutputFormat.Jsonl => new JsonLineProxyWriter(stream),
131+
OutputFormat.Xml => new XmlProxyWriter(stream, isPretty),
132+
OutputFormat.Csv => new CsvProxyWriter(stream, isPretty),
133+
OutputFormat.Psv => new PsvProxyWriter(stream, isPretty),
134+
OutputFormat.Tsv => new TsvProxyWriter(stream, isPretty),
135+
_ => new PlainProxyWriter(stream)
136+
};
137+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.ComponentModel;
2+
3+
using Proxxi.Core.Models;
4+
5+
using Spectre.Console.Cli;
6+
7+
namespace Proxxi.Cli.Commands.Fetch;
8+
9+
public class FetchCommandSettings : CommandSettings
10+
{
11+
[CommandArgument(0, "<ID>")]
12+
[Description("The plugin ID or alias to fetch proxies from")]
13+
public required string Id { get; init; }
14+
15+
[CommandOption("-o|--output <PATH>")]
16+
[Description("Write the fetched proxies to the specified file (defaults to stdout)")]
17+
public string? Output { get; init; }
18+
19+
[CommandOption("-f|--format <FORMAT>"), DefaultValue(OutputFormat.Plain)]
20+
[Description("Output format: plain, url, json, jsonl, xml, csv, psv, tsv")]
21+
public OutputFormat Format { get; init; }
22+
23+
[CommandOption("-s|--stream"), DefaultValue(false)]
24+
[Description("Fetch proxies using streaming mode instead of batch mode")]
25+
public bool Stream { get; init; }
26+
27+
[CommandOption("--pretty")]
28+
[Description("Write human-readable, pretty-formatted output")]
29+
public bool Pretty { get; init; }
30+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
3+
using Spectre.Console.Cli;
4+
5+
namespace Proxxi.Cli.Infrastructure.Injection;
6+
7+
public class TypeRegistrar(IServiceCollection services) : ITypeRegistrar
8+
{
9+
public void Register(Type service, Type implementation) =>
10+
services.AddSingleton(service, implementation);
11+
12+
public void RegisterInstance(Type service, object implementation) =>
13+
services.AddSingleton(service, implementation);
14+
15+
public void RegisterLazy(Type service, Func<object> factory) =>
16+
services.AddSingleton(service, _ => factory());
17+
18+
public ITypeResolver Build() =>
19+
new TypeResolver(services.BuildServiceProvider());
20+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Spectre.Console.Cli;
2+
3+
namespace Proxxi.Cli.Infrastructure.Injection;
4+
5+
public class TypeResolver(IServiceProvider provider) : ITypeResolver
6+
{
7+
public object? Resolve(Type? type) =>
8+
type != null ? provider.GetService(type) : null;
9+
}

src/Proxxi.Cli/Program.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// See https://aka.ms/new-console-template for more information
2+
3+
using System.Reflection;
4+
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
using Proxxi.Cli.Commands.Fetch;
8+
using Proxxi.Cli.Infrastructure.Injection;
9+
using Proxxi.Core.Extensions;
10+
using Proxxi.Core.Providers;
11+
using Proxxi.Plugin.Loader.PluginLoaders;
12+
13+
using Spectre.Console;
14+
using Spectre.Console.Cli;
15+
16+
string applicationName = Assembly.GetExecutingAssembly().GetName().Name ?? "proxxi";
17+
string applicationVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown";
18+
19+
string userDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
20+
string defaultDir = Path.Combine(userDir, ".proxxi");
21+
22+
var proxxiDir = Environment.GetEnvironmentVariable("PROXXI_DIR") ?? defaultDir;
23+
24+
var services = new ServiceCollection();
25+
26+
services.AddProxxiPaths(proxxiDir);
27+
28+
services.AddSingleton(AnsiConsole.Console);
29+
30+
services.AddSingleton<IPluginLoader, PluginLoader>();
31+
32+
services.AddSingleton<IPluginConfigProvider, JsonPluginConfigProvider>();
33+
34+
var app = new CommandApp(new TypeRegistrar(services));
35+
36+
app.Configure(config =>
37+
{
38+
config.SetApplicationName(applicationName);
39+
config.SetApplicationVersion(applicationVersion);
40+
41+
config.AddCommand<FetchCommand>("fetch")
42+
.WithDescription("Fetch a proxies from source.")
43+
.WithExample("fetch", "test.plugin", "-o", "proxies.csv");
44+
45+
#if DEBUG
46+
config.ValidateExamples();
47+
config.PropagateExceptions();
48+
#endif
49+
});
50+
51+
return await app.RunAsync(args);

src/Proxxi.Cli/Proxxi.Cli.csproj

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
9+
<AssemblyName>proxxi</AssemblyName>
10+
<Version>$(GitVersion_Version)</Version>
11+
12+
<PublishSingleFile>true</PublishSingleFile>
13+
<SelfContained>true</SelfContained>
14+
15+
<DebugType>embedded</DebugType>
16+
</PropertyGroup>
17+
18+
<ItemGroup>
19+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
20+
<PackageReference Include="Spectre.Console" Version="0.53.1" />
21+
<PackageReference Include="Spectre.Console.Cli" Version="0.53.1" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<ProjectReference Include="..\Proxxi.Core\Proxxi.Core.csproj" />
26+
<ProjectReference Include="..\Proxxi.Plugin.Loader\Proxxi.Plugin.Loader.csproj" />
27+
</ItemGroup>
28+
29+
</Project>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Proxxi.Core.Options;
2+
3+
namespace Proxxi.Core.Extensions;
4+
5+
public static class ProxxiPathsOptionsExtension
6+
{
7+
public static void EnsureCreated(this ProxxiPathsOptions options)
8+
{
9+
CreateDirectory(options.ProxxiDir, true);
10+
CreateDirectory(options.TmpDir);
11+
CreateDirectory(options.PluginsDir);
12+
13+
EnsureFile(options.PluginsFile, "[]");
14+
}
15+
16+
private static void CreateDirectory(string path, bool hiddenOnWindows = false)
17+
{
18+
if (Directory.Exists(path))
19+
return;
20+
21+
var info = Directory.CreateDirectory(path);
22+
23+
if (hiddenOnWindows && OperatingSystem.IsWindows())
24+
info.Attributes |= FileAttributes.Hidden;
25+
}
26+
27+
private static void EnsureFile(string path, string defaultContent = "")
28+
{
29+
if (!File.Exists(path))
30+
File.WriteAllText(path, defaultContent);
31+
}
32+
}

src/Proxxi.Core/Extensions/ServiceCollectionExtensions.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ namespace Proxxi.Core.Extensions;
88

99
public static class ServiceCollectionExtensions
1010
{
11-
public static IServiceCollection AddProxxiPaths(this IServiceCollection services, string proxxiDir) =>
12-
services.AddSingleton(OptionsFactory.Create(new ProxxiPathsOptions { ProxxiDir = proxxiDir }));
11+
public static IServiceCollection AddProxxiPaths(this IServiceCollection services, string proxxiDir)
12+
{
13+
var options = OptionsFactory.Create(new ProxxiPathsOptions { ProxxiDir = proxxiDir });
14+
15+
options.Value.EnsureCreated();
16+
17+
services.AddSingleton(options);
18+
19+
return services;
20+
}
1321
}

0 commit comments

Comments
 (0)