aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/Crupest.SecretTool/.gitignore7
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool.sln30
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/.gitignore1
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Config.cs95
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Controller.cs113
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj34
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs26
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs324
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs123
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Program.cs113
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml13
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs76
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs31
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Routing.cs155
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs20
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs40
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs56
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/Template.cs231
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs271
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs25
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs31
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/config.json.template63
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template55
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/hosts.txt2
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/proxy.txt50
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template45
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json11
-rw-r--r--tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json14
-rwxr-xr-xtools/Crupest.SecretTool/build-secret.bash41
-rw-r--r--tools/Crupest.SecretTool/build-secret.ps125
-rwxr-xr-xtools/Crupest.SecretTool/tools/cru-proxy-edit12
-rwxr-xr-xtools/Crupest.SecretTool/tools/cru-proxy-log13
-rw-r--r--tools/Crupest.SecretTool/tools/crupest-secret-tool.service8
-rw-r--r--tools/Crupest.SecretTool/tools/crupest-secret-tool.xml49
-rw-r--r--tools/Crupest.SecretTool/tools/life.crupest.secret-tool.plist18
-rw-r--r--tools/cru-py/.gitignore2
-rw-r--r--tools/cru-py/.python-version1
-rw-r--r--tools/cru-py/cru/__init__.py60
-rw-r--r--tools/cru-py/cru/_base.py101
-rw-r--r--tools/cru-py/cru/_const.py49
-rw-r--r--tools/cru-py/cru/_decorator.py97
-rw-r--r--tools/cru-py/cru/_error.py89
-rw-r--r--tools/cru-py/cru/_event.py61
-rw-r--r--tools/cru-py/cru/_func.py172
-rw-r--r--tools/cru-py/cru/_helper.py16
-rw-r--r--tools/cru-py/cru/_iter.py466
-rw-r--r--tools/cru-py/cru/_type.py52
-rw-r--r--tools/cru-py/cru/attr.py364
-rw-r--r--tools/cru-py/cru/config.py196
-rw-r--r--tools/cru-py/cru/list.py160
-rw-r--r--tools/cru-py/cru/parsing.py98
-rw-r--r--tools/cru-py/cru/service/__init__.py0
-rw-r--r--tools/cru-py/cru/service/__main__.py20
-rw-r--r--tools/cru-py/cru/service/_app.py34
-rw-r--r--tools/cru-py/cru/service/_base.py449
-rw-r--r--tools/cru-py/cru/service/_config.py446
-rw-r--r--tools/cru-py/cru/service/_external.py81
-rw-r--r--tools/cru-py/cru/service/_nginx.py281
-rw-r--r--tools/cru-py/cru/service/_template.py86
-rw-r--r--tools/cru-py/cru/system.py23
-rw-r--r--tools/cru-py/cru/template.py153
-rw-r--r--tools/cru-py/cru/tool.py82
-rw-r--r--tools/cru-py/cru/value.py292
-rw-r--r--tools/cru-py/poetry.lock80
-rw-r--r--tools/cru-py/pyproject.toml26
-rw-r--r--tools/cru-py/www-dev8
-rwxr-xr-xtools/manage16
-rw-r--r--tools/manage.cmd15
-rwxr-xr-xtools/scripts/neovide-listen43
-rw-r--r--tools/scripts/neovide-listen.ps138
-rwxr-xr-xtools/update-blog5
-rwxr-xr-xtools/utility/rename-tree.py37
72 files changed, 6420 insertions, 0 deletions
diff --git a/tools/Crupest.SecretTool/.gitignore b/tools/Crupest.SecretTool/.gitignore
new file mode 100644
index 0000000..ac4d8a4
--- /dev/null
+++ b/tools/Crupest.SecretTool/.gitignore
@@ -0,0 +1,7 @@
+.vs
+bin
+obj
+*.pubxml.user
+*.csproj.user
+
+publish
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool.sln b/tools/Crupest.SecretTool/Crupest.SecretTool.sln
new file mode 100644
index 0000000..fde4347
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool.sln
@@ -0,0 +1,30 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.7.34024.191
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F4C2CE80-CDF8-4B08-8912-D1F0F14196AD}"
+ ProjectSection(SolutionItems) = preProject
+ .gitignore = .gitignore
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crupest.SecretTool", "Crupest.SecretTool\Crupest.SecretTool.csproj", "{D6335AE4-FD22-49CD-9624-37371F3B4F82}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {B1E8FD9C-9157-4F4E-8265-4B37F30EEC5E}
+ EndGlobalSection
+EndGlobal
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/.gitignore b/tools/Crupest.SecretTool/Crupest.SecretTool/.gitignore
new file mode 100644
index 0000000..c936492
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/.gitignore
@@ -0,0 +1 @@
+vmess.txt
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Config.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Config.cs
new file mode 100644
index 0000000..ff58551
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Config.cs
@@ -0,0 +1,95 @@
+namespace Crupest.SecretTool;
+
+public record ConfigItem(string Value, int LineNumber);
+
+public class DictionaryConfig(string configString, List<string>? requiredKeys = null)
+{
+ private static Dictionary<string, ConfigItem> Parse(string configString, List<string>? requiredKeys = null)
+ {
+ var config = new Dictionary<string, ConfigItem>();
+ var lines = configString.Split('\n');
+ int lineNumber = 1;
+
+ foreach (var line in lines)
+ {
+ var l = line;
+ var beginOfComment = l.IndexOf('#');
+ if (beginOfComment >= 0)
+ {
+ l = line[..beginOfComment];
+ }
+ l = l.Trim();
+ if (!string.IsNullOrEmpty(l))
+ {
+ var equalIndex = l.IndexOf('=');
+ if (equalIndex == -1)
+ {
+ throw new FormatException($"No '=' found in line {lineNumber}.");
+ }
+
+ config.Add(l[..equalIndex].Trim(), new ConfigItem(l[(equalIndex + 1)..].Trim(), lineNumber));
+ }
+
+ lineNumber++;
+ }
+
+ if (requiredKeys is not null)
+ {
+ foreach (var key in requiredKeys)
+ {
+ if (!config.ContainsKey(key))
+ {
+ throw new FormatException($"Required key '{key}' not found in config.");
+ }
+ }
+ }
+
+ return config;
+ }
+
+ public string ConfigString { get; } = configString;
+ public List<string>? RequiredKeys { get; } = requiredKeys;
+ public Dictionary<string, ConfigItem> Config { get; } = Parse(configString);
+ public ConfigItem GetItemCaseInsensitive(string key)
+ {
+ foreach (var (originalKey, value) in Config)
+ {
+ if (string.Equals(originalKey, key, StringComparison.OrdinalIgnoreCase))
+ {
+ return value;
+ }
+ }
+ throw new KeyNotFoundException($"Key '{key}' not found in config case-insensitively.");
+ }
+}
+
+public class ListConfig(string configString)
+{
+ private static List<ConfigItem> Parse(string configString)
+ {
+ var config = new List<ConfigItem>();
+ var lines = configString.Split('\n');
+ int lineNumber = 1;
+
+ foreach (var line in lines)
+ {
+ var l = line;
+ var beginOfComment = l.IndexOf('#');
+ if (beginOfComment >= 0)
+ {
+ l = line[..beginOfComment];
+ }
+ l = l.Trim();
+ if (!string.IsNullOrEmpty(l))
+ {
+ config.Add(new ConfigItem(l, lineNumber));
+ }
+ lineNumber++;
+ }
+
+ return config;
+ }
+
+ public string ConfigString { get; } = configString;
+ public List<ConfigItem> Config { get; } = Parse(configString);
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Controller.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Controller.cs
new file mode 100644
index 0000000..0803b01
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Controller.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics;
+
+namespace Crupest.SecretTool;
+
+public class Controller(string executablePath, string configPath, string? assetPath)
+{
+ public const string ToolAssetEnvironmentVariableName = "v2ray.location.asset";
+
+ public static string? FindExecutable(string contentDir, out bool isLocal, string? executableName = null)
+ {
+ isLocal = false;
+ executableName ??= "v2ray";
+
+ if (OperatingSystem.IsWindows())
+ {
+ executableName += ".exe";
+ }
+
+ var localToolPath = Path.Combine(contentDir, executableName);
+ if (File.Exists(localToolPath))
+ {
+ isLocal = true;
+ return localToolPath;
+ }
+
+ var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator);
+ if (paths is not null)
+ {
+ foreach (var p in paths)
+ {
+ var toolPath = Path.Combine(p, executableName);
+ if (File.Exists(toolPath))
+ {
+ return toolPath;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public string ExecutablePath { get; } = executablePath;
+ public string ConfigPath { get; } = configPath;
+ public string? AssetPath { get; } = assetPath;
+ public Process? CurrentProcess { get; private set; }
+
+ private Process CreateProcess()
+ {
+ var process = new Process();
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = ExecutablePath,
+ };
+ startInfo.ArgumentList.Add("run");
+ startInfo.ArgumentList.Add("-c");
+ startInfo.ArgumentList.Add(ConfigPath);
+ if (AssetPath is not null)
+ {
+ startInfo.EnvironmentVariables[ToolAssetEnvironmentVariableName] = AssetPath;
+ }
+
+ process.StartInfo = startInfo;
+ process.OutputDataReceived += (_, args) =>
+ {
+ Console.Out.Write(args.Data);
+ };
+ process.ErrorDataReceived += (_, args) =>
+ {
+ Console.Error.WriteLine(args.Data);
+ };
+
+ return process;
+ }
+
+ public void Stop()
+ {
+ if (CurrentProcess is not null)
+ {
+ CurrentProcess.Kill();
+ CurrentProcess.Dispose();
+ CurrentProcess = null;
+ Console.WriteLine("V2ray stopped.");
+ }
+ }
+
+ public void Start(bool stopOld = false)
+ {
+ if (stopOld) Stop();
+
+ if (CurrentProcess is null)
+ {
+ CurrentProcess = CreateProcess();
+ CurrentProcess.EnableRaisingEvents = true;
+ CurrentProcess.Exited += (_, _) =>
+ {
+ if (CurrentProcess.ExitCode != 0)
+ {
+ const string message = "V2ray exited with error.";
+ Console.Error.WriteLine(message);
+ throw new Exception(message);
+ }
+ };
+ CurrentProcess.Start();
+ Console.WriteLine("V2ray started.");
+ }
+ }
+
+ public void Restart()
+ {
+ Start(true);
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj b/tools/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj
new file mode 100644
index 0000000..2502e74
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj
@@ -0,0 +1,34 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net8.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Update="config.json.template">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="proxy.txt">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="vmess.txt">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="hosts.txt">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="sing-config.json.template">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="sing-inbounds-mobile.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="sing-inbounds-pc.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+</Project>
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs
new file mode 100644
index 0000000..26e9231
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs
@@ -0,0 +1,26 @@
+namespace Crupest.SecretTool;
+
+public class FileWatcher(string directory, List<string> fileNames)
+{
+ public string Directory { get; set; } = directory;
+ public List<string> FileNames { get; set; } = fileNames;
+
+ public delegate void OnChangedHandler();
+ public event OnChangedHandler? OnChanged;
+
+ public void Run()
+ {
+ var sourceWatcher = new FileSystemWatcher(Directory);
+ foreach (var fileName in FileNames)
+ {
+ sourceWatcher.Filters.Add(fileName);
+ }
+ sourceWatcher.NotifyFilter = NotifyFilters.LastWrite;
+
+ while (true)
+ {
+ var result = sourceWatcher.WaitForChanged(WatcherChangeTypes.Changed | WatcherChangeTypes.Created);
+ OnChanged?.Invoke();
+ }
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs
new file mode 100644
index 0000000..8f4c171
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs
@@ -0,0 +1,324 @@
+using System.IO.Compression;
+
+namespace Crupest.SecretTool;
+
+public interface IGeoSiteEntry
+{
+ bool IsInclude { get; }
+ string Value { get; }
+}
+
+public record GeoSiteIncludeEntry(string Value, string ContainingSite) : IGeoSiteEntry
+{
+ public bool IsInclude => true;
+}
+
+public record GeoSiteRuleEntry(HostMatchKind Kind, string Value, List<string> Attributes, string ContainingSite) : IGeoSiteEntry
+{
+ public bool IsInclude => false;
+
+ public RoutingRuleMatcher GetRoutingRuleMatcher() => new(Kind, Value);
+}
+
+public record GeoSite(string Name, List<IGeoSiteEntry> Entries)
+{
+ public static GeoSite Parse(string name, string str)
+ {
+ List<IGeoSiteEntry> entries = [];
+ var listConfig = new ListConfig(str);
+ foreach (var item in listConfig.Config)
+ {
+ var (value, line) = item;
+
+ if (value.StartsWith("include:"))
+ {
+ var include = value["include:".Length..].Trim();
+ if (include.Length == 0 || include.Contains(' '))
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Invalid include value.");
+ }
+ entries.Add(new GeoSiteIncludeEntry(include, name));
+ continue;
+ }
+
+ var segments = value.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length > 2)
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. More than one ':'.");
+ }
+
+ HostMatchKind kind;
+ if (segments.Length == 2)
+ {
+ kind = segments[0] switch
+ {
+ "domain" => kind = HostMatchKind.DomainSuffix,
+ "full" => kind = HostMatchKind.DomainFull,
+ "keyword" => kind = HostMatchKind.DomainKeyword,
+ "regexp" => kind = HostMatchKind.DomainRegex,
+ _ => throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Unknown matcher.")
+ };
+ }
+ else
+ {
+ kind = HostMatchKind.DomainSuffix;
+ }
+
+ var domainSegments = segments[^1].Split('@', StringSplitOptions.TrimEntries);
+ var domain = domainSegments[0];
+ if (kind != HostMatchKind.DomainRegex && Uri.CheckHostName(domain) != UriHostNameType.Dns)
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Invalid domain.");
+ }
+
+ List<string> attributes = [];
+ foreach (var s in domainSegments[1..])
+ {
+ if (s.Length == 0)
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Empty attribute value.");
+ }
+ attributes.Add(s);
+ }
+
+ entries.Add(new GeoSiteRuleEntry(kind, domain, attributes, name));
+ }
+ return new GeoSite(name, entries);
+ }
+}
+
+public class GeoSiteData(string directory)
+{
+ private static List<GeoSite> Parse(string directory)
+ {
+ var sites = new List<GeoSite>();
+ foreach (var path in Directory.GetFileSystemEntries(directory))
+ {
+ var content = File.ReadAllText(path);
+ sites.Add(GeoSite.Parse(Path.GetFileName(path), content));
+ }
+ return sites;
+ }
+
+ public string DataDirectory { get; } = directory;
+
+ public List<GeoSite> Sites { get; } = Parse(directory);
+
+ public GeoSite? GetSite(string name)
+ {
+ return Sites.Where(s => s.Name == name).FirstOrDefault();
+ }
+
+ public List<GeoSiteRuleEntry> GetEntriesRecursive(List<string> sites,
+ List<HostMatchKind>? onlyMatcherKinds = null, List<string>? onlyAttributes = null)
+ {
+ List<GeoSiteRuleEntry> entries = [];
+ HashSet<string> visited = [];
+ HashSet<HostMatchKind>? kinds = onlyMatcherKinds?.ToHashSet();
+
+ void Visit(string site)
+ {
+ if (visited.Contains(site))
+ {
+ return;
+ }
+
+ visited.Add(site);
+ var siteData = GetSite(site);
+ if (siteData == null)
+ {
+ return;
+ }
+ foreach (var entry in siteData.Entries)
+ {
+ if (entry is GeoSiteIncludeEntry includeEntry)
+ {
+ Visit(includeEntry.Value);
+ }
+ else if (entry is GeoSiteRuleEntry geoSiteRuleEntry)
+ {
+ if (kinds != null && !kinds.Contains(geoSiteRuleEntry.Kind))
+ {
+ continue;
+ }
+
+ if (onlyAttributes != null && !geoSiteRuleEntry.Attributes.Intersect(onlyAttributes).Any())
+ {
+ continue;
+ }
+
+ entries.Add(geoSiteRuleEntry);
+ }
+ }
+ }
+
+ foreach (var s in sites)
+ {
+ Visit(s);
+ }
+
+ return entries;
+ }
+}
+
+public class GeoDataManager
+{
+ public const string GeoSiteFileName = "geosite.dat";
+ public const string GeoIpFileName = "geoip.dat";
+ public const string GeoIpCnFileName = "geoip-only-cn-private.dat";
+
+ public static class ToolGithub
+ {
+ public const string Organization = "v2fly";
+ public const string GeoSiteRepository = "domain-list-community";
+ public const string GeoIpRepository = "geoip";
+ public const string GeoSiteReleaseFilename = "dlc.dat";
+ public const string GeoIpReleaseFilename = "geoip.dat";
+ public const string GeoIpCnReleaseFilename = "geoip-only-cn-private.dat";
+ }
+
+ public static GeoDataManager Instance { get; } = new GeoDataManager();
+
+ public record GeoDataAsset(string Name, string FileName, string GithubUser, string GithubRepo, string GithubReleaseFileName);
+
+ public GeoDataManager()
+ {
+ Assets =
+ [
+ new("geosite", GeoSiteFileName, ToolGithub.Organization, ToolGithub.GeoSiteRepository, ToolGithub.GeoSiteReleaseFilename),
+ new("geoip", GeoIpFileName, ToolGithub.Organization, ToolGithub.GeoIpRepository, ToolGithub.GeoIpReleaseFilename),
+ new("geoip-cn", GeoIpCnFileName, ToolGithub.Organization, ToolGithub.GeoIpRepository, ToolGithub.GeoIpCnReleaseFilename),
+ ];
+ }
+
+ public List<GeoDataAsset> Assets { get; set; }
+
+ public GeoSiteData? GeoSiteData { get; set; }
+
+ public GeoSiteData GetOrCreateGeoSiteData(bool clean, bool silent)
+ {
+ if (GeoSiteData is not null) { return GeoSiteData; }
+ GeoSiteData = DownloadAndGenerateGeoSiteData(clean, silent);
+ return GeoSiteData;
+ }
+
+ private static string GetReleaseFileUrl(string user, string repo, string fileName)
+ {
+ return $"https://github.com/{user}/{repo}/releases/latest/download/{fileName}";
+ }
+
+ private static void GithubDownloadRelease(HttpClient httpClient, string user, string repo, string fileName, string outputPath, bool silent)
+ {
+ var url = GetReleaseFileUrl(user, repo, fileName);
+ if (!silent) Console.WriteLine($"Downloading {url} to {outputPath}");
+ using var responseStream = httpClient.GetStreamAsync(url).Result;
+ using var outputFileStream = File.OpenWrite(outputPath);
+ responseStream.CopyTo(outputFileStream);
+ }
+
+ public bool HasAllAssets(string directory, out List<string> missing)
+ {
+ missing = [];
+ foreach (var asset in Assets)
+ {
+ var assetPath = Path.Combine(directory, asset.FileName);
+ if (!File.Exists(assetPath))
+ {
+ missing.Add(asset.Name);
+ }
+ }
+ return missing.Count == 0;
+ }
+
+ public void Download(string outputDir, bool silent)
+ {
+ using var httpClient = new HttpClient();
+
+ foreach (var asset in Assets)
+ {
+ if (!silent)
+ {
+ Console.WriteLine($"Downloading {asset.Name}...");
+ }
+ GithubDownloadRelease(httpClient, asset.GithubUser, asset.GithubRepo, asset.GithubReleaseFileName, Path.Combine(outputDir, asset.FileName), silent);
+ if (!silent)
+ {
+ Console.WriteLine($"Downloaded {asset.Name}!");
+ }
+ }
+
+ if (!File.Exists(Program.RestartLabelFilePath))
+ {
+ File.Create(Program.RestartLabelFilePath);
+ }
+ else
+ {
+ File.SetLastWriteTime(Program.RestartLabelFilePath, DateTime.Now);
+ }
+ }
+
+ private static string GetGithubRepositoryArchiveUrl(string user, string repo)
+ {
+ return $"https://github.com/{user}/{repo}/archive/refs/heads/master.zip";
+ }
+
+ private static void GithubDownloadRepository(HttpClient httpClient, string user, string repo, string outputPath, bool silent)
+ {
+ var url = GetGithubRepositoryArchiveUrl(user, repo);
+ if (!silent) { Console.WriteLine($"Begin to download data from {url} to {outputPath}."); }
+ using var responseStream = httpClient.GetStreamAsync(url).Result;
+ using var outputFileStream = File.OpenWrite(outputPath);
+ responseStream.CopyTo(outputFileStream);
+ if (!silent) { Console.WriteLine("Succeeded to download."); }
+ }
+
+ private static void Unzip(string zipPath, string outputPath)
+ {
+ using var zip = ZipFile.OpenRead(zipPath) ?? throw new Exception($"Failed to open zip file {zipPath}");
+ zip.ExtractToDirectory(outputPath);
+ }
+
+ private static string DownloadAndExtractGeoDataRepository(bool cleanTempDirIfFailed, bool silent, out string tempDirectoryPath)
+ {
+ tempDirectoryPath = "";
+ const string zipFileName = "v2ray-geosite-master.zip";
+ using var httpClient = new HttpClient();
+ var tempDirectory = Directory.CreateTempSubdirectory(Program.Name);
+ tempDirectoryPath = tempDirectory.FullName;
+ try
+ {
+ var archivePath = Path.Combine(tempDirectoryPath, zipFileName);
+ var extractPath = Path.Combine(tempDirectoryPath, "repo");
+ GithubDownloadRepository(httpClient, ToolGithub.Organization, ToolGithub.GeoSiteRepository, archivePath, silent);
+ if (!silent) { Console.WriteLine($"Extract geo data to {extractPath}."); }
+ Directory.CreateDirectory(extractPath);
+ Unzip(archivePath, extractPath);
+ if (!silent) { Console.WriteLine($"Extraction done."); }
+ return Path.Join(extractPath, "domain-list-community-master");
+ }
+ catch (Exception)
+ {
+ if (cleanTempDirIfFailed)
+ {
+ Directory.Delete(tempDirectoryPath, true);
+ }
+ throw;
+ }
+ }
+
+ private static GeoSiteData DownloadAndGenerateGeoSiteData(bool clean, bool silent)
+ {
+ var repoDirectory = DownloadAndExtractGeoDataRepository(clean, silent, out var tempDirectoryPath);
+ try
+ {
+ return new GeoSiteData(Path.Join(repoDirectory, "data"));
+ }
+ finally
+ {
+ if (clean)
+ {
+ Directory.Delete(tempDirectoryPath, true);
+ }
+ }
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs
new file mode 100644
index 0000000..858333d
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs
@@ -0,0 +1,123 @@
+namespace Crupest.SecretTool;
+
+public enum HostMatchKind
+{
+ DomainFull,
+ DomainSuffix,
+ DomainKeyword,
+ DomainRegex,
+ Ip,
+ GeoSite,
+ GeoIp,
+}
+
+public static class HostMatchKindExtensions
+{
+ public static bool IsDomain(this HostMatchKind kind)
+ {
+ return kind.IsNonRegexDomain() || kind == HostMatchKind.DomainRegex;
+ }
+
+ public static bool IsNonRegexDomain(this HostMatchKind kind)
+ {
+ return kind is HostMatchKind.DomainFull or HostMatchKind.DomainSuffix or HostMatchKind.DomainKeyword;
+ }
+
+
+ public static List<HostMatchKind> DomainMatchKinds { get; } = [HostMatchKind.DomainFull, HostMatchKind.DomainSuffix, HostMatchKind.DomainKeyword, HostMatchKind.DomainRegex];
+
+ public static List<HostMatchKind> NonRegexDomainMatchKinds { get; } = [HostMatchKind.DomainFull, HostMatchKind.DomainSuffix, HostMatchKind.DomainKeyword];
+
+ public static List<HostMatchKind> SupportedInSingRouteMatchKinds { get; } = [..DomainMatchKinds, HostMatchKind.Ip];
+
+ public static bool IsSupportedInSingRoute(this HostMatchKind kind) => SupportedInSingRouteMatchKinds.Contains(kind);
+}
+
+public record HostMatchConfigItem(HostMatchKind Kind, string MatchString, List<string> Values);
+
+public class HostMatchConfig(string configString, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1)
+{
+ private static List<HostMatchConfigItem> Parse(string configString, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1)
+ {
+ var items = new ListConfig(configString).Config;
+ var result = new List<HostMatchConfigItem>();
+
+ foreach (var item in items)
+ {
+ var lineNumber = item.LineNumber;
+ var line = item.Value;
+ var hasExplicitMatchKind = false;
+ var segments = line.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList();
+
+ foreach (var matchKind in Enum.GetValues<HostMatchKind>())
+ {
+ var matchKindName = Enum.GetName(matchKind) ?? throw new Exception("No such match kind.");
+ if (segments[0] == matchKindName)
+ {
+ hasExplicitMatchKind = true;
+
+ if (segments.Count < 2)
+ {
+ throw new FormatException($"Explicit match item needs a value in line {lineNumber}.");
+ }
+ if (allowedMatchKinds.Contains(matchKind))
+ {
+ if (matchKind.IsNonRegexDomain() && Uri.CheckHostName(matchKindName) != UriHostNameType.Dns)
+ {
+ throw new FormatException($"Invalid domain format in line {lineNumber}.");
+ }
+
+ var components = segments[2..].ToList();
+ if (minComponentCount > 0 && components.Count < minComponentCount)
+ {
+ throw new FormatException($"Too few components in line {lineNumber}, at least {minComponentCount} required.");
+ }
+ if (maxComponentCount >= 0 && components.Count > maxComponentCount)
+ {
+ throw new FormatException($"Too many components in line {lineNumber}, only {maxComponentCount} allowed.");
+ }
+ result.Add(new HostMatchConfigItem(matchKind, segments[1], components));
+ }
+ else
+ {
+ throw new FormatException($"Match kind {matchKindName} is not allowed at line {lineNumber}.");
+ }
+ }
+ }
+
+ if (!hasExplicitMatchKind)
+ {
+ if (minComponentCount > 0 && segments.Count - 1 < minComponentCount)
+ {
+ throw new FormatException($"Too few components in line {lineNumber}, at least {minComponentCount} required.");
+ }
+ if (maxComponentCount >= 0 && segments.Count - 1 > maxComponentCount)
+ {
+ throw new FormatException($"Too many components in line {lineNumber}, only {maxComponentCount} allowed.");
+ }
+ result.Add(new HostMatchConfigItem(HostMatchKind.DomainSuffix, segments[0], segments.Count == 1 ? [] : segments[1..]));
+ }
+ }
+ return result;
+ }
+
+ public string ConfigString { get; } = configString;
+ public List<HostMatchKind> AllowedMatchKinds { get; } = allowedMatchKinds;
+ public int MinComponentCount { get; } = minComponentCount;
+ public int MaxComponentCount { get; } = maxComponentCount;
+ public List<HostMatchConfigItem> Items { get; } = Parse(configString, allowedMatchKinds, minComponentCount, maxComponentCount);
+}
+
+public class HostMatchConfigFile
+{
+ public HostMatchConfigFile(string path, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1)
+ {
+ Path = path;
+ FileContent = File.ReadAllText(path);
+ Config = new HostMatchConfig(FileContent, allowedMatchKinds, minComponentCount, maxComponentCount); ;
+ }
+
+ public string Path { get; }
+ public string FileContent { get; }
+ public HostMatchConfig Config { get; }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Program.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Program.cs
new file mode 100644
index 0000000..18b1ac0
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Program.cs
@@ -0,0 +1,113 @@
+using System.Reflection;
+
+namespace Crupest.SecretTool;
+
+public static class Program
+{
+ public static string Name { get; } = typeof(Program).Namespace ?? throw new Exception("Can't get the name of Crupest.SecretTool.");
+
+ public static string CrupestSecretToolDirectory { get; } =
+ Environment.GetEnvironmentVariable("CRUPEST_V2RAY_DIR") ??
+ Path.GetFullPath(Path.GetDirectoryName(
+ Assembly.GetExecutingAssembly().Location) ?? throw new Exception("Can't get the path of Crupest.SecretTool."));
+
+ private const string ConfigOutputFileName = "config.json";
+ private const string SurgeRuleSetChinaOutputFileName = "ChinaRuleSet.txt";
+ private const string SurgeRuleSetGlobalOutputFileName = "GlobalRuleSet.txt";
+
+ public const string RestartLabelFileName = "restart.label";
+ public static string RestartLabelFilePath { get; } = Path.Combine(CrupestSecretToolDirectory, RestartLabelFileName);
+
+ public static void RunToolAndWatchConfigChange()
+ {
+ var executablePath = Controller.FindExecutable(CrupestSecretToolDirectory, out var isLocal) ??
+ throw new Exception("Can't find v2ray executable either in Crupest.SecretTool directory or in PATH.");
+
+ string? assetsPath;
+ if (isLocal)
+ {
+ assetsPath = CrupestSecretToolDirectory;
+ var assetsComplete = GeoDataManager.Instance.HasAllAssets(CrupestSecretToolDirectory, out var missing);
+ if (!assetsComplete)
+ {
+ throw new Exception($"Missing assets: {string.Join(", ", missing)} in {CrupestSecretToolDirectory}. This v2ray is local. So only use assets in Crupest.SecretTool directory.");
+ }
+ }
+ else
+ {
+ assetsPath = CrupestSecretToolDirectory;
+ var assetsComplete = GeoDataManager.Instance.HasAllAssets(CrupestSecretToolDirectory, out var missing);
+ if (!assetsComplete)
+ {
+ Console.WriteLine($"Missing assets: {string.Join(", ", missing)} in {CrupestSecretToolDirectory}. This v2ray is global. So fallback to its own assets.");
+ assetsPath = null;
+ }
+ }
+
+ var controller = new Controller(executablePath, Path.Combine(CrupestSecretToolDirectory, ConfigOutputFileName), assetsPath);
+ var configFileWatcher = new FileWatcher(CrupestSecretToolDirectory,
+ [.. ToolConfig.ConfigFileNames, RestartLabelFileName]);
+
+ ToolConfig.FromDirectoryAndWriteToFile(CrupestSecretToolDirectory, Path.Join(CrupestSecretToolDirectory, ConfigOutputFileName));
+ controller.Start();
+
+ configFileWatcher.OnChanged += () =>
+ {
+ ToolConfig.FromDirectoryAndWriteToFile(CrupestSecretToolDirectory, Path.Join(CrupestSecretToolDirectory, ConfigOutputFileName));
+ controller.Restart();
+ };
+
+ configFileWatcher.Run();
+ }
+
+ public static void Main(string[] args)
+ {
+ if (args.Length != 0)
+ {
+ var verb = args[0].ToLower();
+ if (verb == "download-geodata" || verb == "dg")
+ {
+ if (args.Length != 1)
+ {
+ throw new Exception("Invalid command line arguments. download-geodata requires no arguments.");
+ }
+ GeoDataManager.Instance.Download(CrupestSecretToolDirectory, false);
+ return;
+ }
+ else if (verb == "generate-surge-rule-set" || verb == "gsr")
+ {
+ if (args.Length != 1)
+ {
+ throw new Exception("Invalid command line arguments. download-geodata requires no arguments.");
+ }
+ SurgeConfigGenerator.GenerateTo(
+ CrupestSecretToolDirectory,
+ Path.Join(CrupestSecretToolDirectory, SurgeRuleSetChinaOutputFileName),
+ Path.Join(CrupestSecretToolDirectory, SurgeRuleSetGlobalOutputFileName),
+ true, true
+ );
+ return;
+ }
+ else if (verb == "generate-sing-config" || verb == "gs")
+ {
+ if (args.Length != 2 || args[1].ToLower() is not ("pc" or "mobile"))
+ {
+ throw new Exception("Invalid command line arguments. generate-sing-config requires 1 argument. The argument must be either 'pc' or 'mobile'.");
+ }
+
+ var config = SingToolConfig.FromDirectory(CrupestSecretToolDirectory, args[1].ToLower() == "mobile", true, true);
+ Console.Out.WriteLine(config.ToSingConfigString());
+ return;
+ }
+ else if (verb == "generate" || verb == "g")
+ {
+ var config = ToolConfig.FromDirectory(CrupestSecretToolDirectory);
+ Console.Out.WriteLine(config.ToJsonStringV4());
+ return;
+ }
+ throw new Exception("Invalid command line arguments.");
+ }
+
+ RunToolAndWatchConfigChange();
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml b/tools/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml
new file mode 100644
index 0000000..5fca454
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+https://go.microsoft.com/fwlink/?LinkID=208121.
+-->
+<Project>
+ <PropertyGroup>
+ <Configuration>Release</Configuration>
+ <Platform>Any CPU</Platform>
+ <PublishDir>bin\Release\net8.0\publish\</PublishDir>
+ <PublishProtocol>FileSystem</PublishProtocol>
+ <_TargetId>Folder</_TargetId>
+ </PropertyGroup>
+</Project> \ No newline at end of file
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs
new file mode 100644
index 0000000..d2703ba
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs
@@ -0,0 +1,76 @@
+namespace Crupest.SecretTool;
+
+public abstract class Proxy(string tag) : IV4ConfigObject, ISingConfigObject
+{
+ public string Tag { get; set; } = tag;
+
+ public abstract V4ConfigJsonObjects.Outbound ToJsonObjectV4();
+ public abstract SingConfigJsonObjects.OutboundBase ToJsonObjectSing();
+
+ object IV4ConfigObject.ToJsonObjectV4()
+ {
+ return ToJsonObjectV4();
+ }
+
+ object ISingConfigObject.ToJsonObjectSing()
+ {
+ return ToJsonObjectSing();
+ }
+}
+
+public class HttpProxy(string host, int port, string tag) : Proxy(tag)
+{
+ public string Host { get; set; } = host;
+ public int Port { get; set; } = port;
+
+ public override SingConfigJsonObjects.OutboundBase ToJsonObjectSing()
+ {
+ throw new NotImplementedException("Http proxy is not supported in sing now.");
+ }
+
+ public override V4ConfigJsonObjects.Outbound ToJsonObjectV4()
+ {
+ return new V4ConfigJsonObjects.Outbound(Tag, "http",
+ new V4ConfigJsonObjects.HttpOutboundSettings([new V4ConfigJsonObjects.HttpOutboundServer(Host, Port, [])]),
+ null
+ );
+ }
+}
+
+
+public class VmessProxy(string host, int port, string userId, string path, string tag) : Proxy(tag)
+{
+ public string Host { get; set; } = host;
+ public int Port { get; set; } = port;
+ public string Path { get; set; } = path;
+ public string UserId { get; set; } = userId;
+
+ public override SingConfigJsonObjects.OutboundBase ToJsonObjectSing()
+ {
+ return new SingConfigJsonObjects.VmessOutbound(Tag, Host, Port, UserId,
+ Transport: new SingConfigJsonObjects.V2rayWebsocketTransport(Path, new Dictionary<string, string> { { "Host", Host } }),
+ Tls: new SingConfigJsonObjects.OutboundTls(true));
+ }
+
+ public override V4ConfigJsonObjects.Outbound ToJsonObjectV4()
+ {
+ return new V4ConfigJsonObjects.Outbound(Tag, "vmess",
+ new V4ConfigJsonObjects.VmessOutboundSettings(
+ [new V4ConfigJsonObjects.VnextServer(Host, Port, [new V4ConfigJsonObjects.VnextServerUser(UserId, 0, "auto", 0)])]),
+ new V4ConfigJsonObjects.WsStreamSettings("ws", "tls", new V4ConfigJsonObjects.WsSettings(Path, new() { ["Host"] = Host }))
+ );
+ }
+
+ public static VmessProxy CreateFromConfigString(string configString, string tag)
+ {
+ var config = new DictionaryConfig(configString, ["host", "port", "userid", "path"]);
+ var portString = config.GetItemCaseInsensitive("port").Value;
+ if (!int.TryParse(portString, out var port) || port <= 0)
+ {
+ throw new FormatException($"Invalid port number: {portString}: not an integer or is a invalid number.");
+ }
+ return new VmessProxy(config.GetItemCaseInsensitive("host").Value, port,
+ config.GetItemCaseInsensitive("userid").Value, config.GetItemCaseInsensitive("path").Value, tag
+ );
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs
new file mode 100644
index 0000000..81698a3
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs
@@ -0,0 +1,31 @@
+namespace Crupest.SecretTool;
+
+public class ProxyFile : HostMatchConfigFile
+{
+ public ProxyFile(string path) : base(path, [.. Enum.GetValues<HostMatchKind>()], maxComponentCount: 0)
+ {
+ RoutingRuleMatchers = Config.Items.Select(i => new RoutingRuleMatcher(i.Kind, i.MatchString)).ToList();
+ }
+
+ public List<RoutingRuleMatcher> RoutingRuleMatchers { get; }
+
+ public List<RoutingRuleMatcher> GetChinaRulesByGeoSite(GeoSiteData geoSiteData)
+ {
+ var geoSites = RoutingRuleMatchers.Where(m => m.MatchKind == HostMatchKind.GeoSite).Select(i => i.MatchString).ToList();
+ return geoSiteData.GetEntriesRecursive(geoSites, HostMatchKindExtensions.DomainMatchKinds, ["cn"]).Select(e => e.GetRoutingRuleMatcher()).ToList();
+ }
+
+ public List<RoutingRuleMatcher> GetRulesFlattenGeoSite(GeoSiteData geoSiteData, bool noCn = false)
+ {
+ var geoSites = RoutingRuleMatchers.Where(m => m.MatchKind == HostMatchKind.GeoSite).Select(i => i.MatchString).ToList();
+ var flattenGeoSiteRules = geoSiteData.GetEntriesRecursive(geoSites, HostMatchKindExtensions.DomainMatchKinds)
+ .Where(e => !noCn || !e.Attributes.Contains("cn"))
+ .Select(e => e.GetRoutingRuleMatcher())
+ .ToList();
+ var otherRules = RoutingRuleMatchers.Where(m => m.MatchKind != HostMatchKind.GeoSite).ToList();
+ return [
+ ..flattenGeoSiteRules,
+ ..otherRules
+ ];
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Routing.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Routing.cs
new file mode 100644
index 0000000..fdf1b93
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Routing.cs
@@ -0,0 +1,155 @@
+namespace Crupest.SecretTool;
+
+public record RoutingRuleMatcher(HostMatchKind MatchKind, string MatchString)
+{
+ public RoutingRule ToRoutingRule(string OutboundTag) => new(MatchKind, MatchString, OutboundTag);
+}
+
+public record RoutingRule(HostMatchKind MatchKind, string MatchString, string OutboundTag) : IV4ConfigObject
+{
+ public string ToolConfigString => MatchKind switch
+ {
+ HostMatchKind.DomainFull => $"full:{MatchString}",
+ HostMatchKind.DomainSuffix => $"domain:{MatchString}",
+ HostMatchKind.DomainKeyword => MatchString,
+ HostMatchKind.DomainRegex => $"regexp:{MatchString}",
+ HostMatchKind.Ip => MatchString,
+ HostMatchKind.GeoSite => $"geosite:{MatchString}",
+ HostMatchKind.GeoIp => $"geoip:{MatchString}",
+ _ => throw new ArgumentException("Invalid matcher kind.")
+ };
+
+ public string ToolConfigStringSing => MatchKind.IsSupportedInSingRoute() ? MatchString : throw new ArgumentException("Unsupported matcher kind for sing.");
+
+ public static Dictionary<string, List<RoutingRule>> GroupByOutboundTag(List<RoutingRule> rules)
+ => rules.GroupBy(r => r.OutboundTag).Select(g => (g.Key, g.ToList())).ToDictionary();
+
+ public static Dictionary<HostMatchKind, List<RoutingRule>> GroupByMatchKind(List<RoutingRule> rules)
+ => rules.GroupBy(r => r.MatchKind).Select(g => (g.Key, g.ToList())).ToDictionary();
+
+ public static List<List<RoutingRule>> GroupByOutboundTagAndMatcherKind(List<RoutingRule> rules)
+ => GroupByOutboundTag(rules).Values.SelectMany((groupByTag) => GroupByMatchKind(groupByTag).Values).ToList();
+
+ public static SingConfigJsonObjects.RouteRule ListToJsonObjectSing(List<RoutingRule> rules)
+ {
+ if (rules.Count == 0)
+ {
+ throw new ArgumentException("Rule list is empty.");
+ }
+
+ var outboundTag = rules[0].OutboundTag;
+
+ if (rules.Any(r => !r.MatchKind.IsSupportedInSingRoute()))
+ {
+ throw new ArgumentException("Rules must have matcher kinds supported in sing.");
+ }
+
+ if (rules.Any(r => r.OutboundTag != outboundTag))
+ {
+ throw new ArgumentException("Rules must have the same outbound tag.");
+ }
+
+ return new SingConfigJsonObjects.RouteRule(Outbound: outboundTag,
+ Domain: rules.Where(r => r.MatchKind == HostMatchKind.DomainFull).Select(r => r.ToolConfigStringSing).ToList(),
+ DomainSuffix: rules.Where(r => r.MatchKind == HostMatchKind.DomainSuffix).Select(r => r.ToolConfigStringSing).ToList(),
+ DomainKeyword: rules.Where(r => r.MatchKind == HostMatchKind.DomainKeyword).Select(r => r.ToolConfigStringSing).ToList(),
+ DomainRegex: rules.Where(r => r.MatchKind == HostMatchKind.DomainRegex).Select(r => r.ToolConfigStringSing).ToList(),
+ IpCidr: rules.Where(r => r.MatchKind == HostMatchKind.Ip).Select(r => r.ToolConfigStringSing).ToList()
+ );
+ }
+
+ public static V4ConfigJsonObjects.RoutingRule ListToJsonObject(List<RoutingRule> rules)
+ {
+ if (rules.Count == 0)
+ {
+ throw new ArgumentException("Rule list is empty.");
+ }
+
+ var matchKind = rules[0].MatchKind;
+ var outboundTag = rules[0].OutboundTag;
+
+ if (rules.Any(r => r.OutboundTag != outboundTag) || rules.Any(r => r.MatchKind != matchKind))
+ {
+ throw new ArgumentException("Rules must have the same matcher kind and outbound tag.");
+ }
+
+ List<string> toolConfigList = rules.Select(r => r.ToolConfigString).ToList();
+
+ return new V4ConfigJsonObjects.RoutingRule(OutboundTag: outboundTag,
+ Ip: (matchKind is HostMatchKind.Ip or HostMatchKind.GeoIp) ? toolConfigList : null,
+ Domains: (matchKind.IsDomain() || matchKind == HostMatchKind.GeoSite) ? toolConfigList : null
+ );
+ }
+
+ public RoutingRule CloneGeositeWithCnAttribute(string outboundTag)
+ {
+ if (MatchKind is not HostMatchKind.GeoSite)
+ {
+ throw new ArgumentException("Matcher kind must be GeoSite.");
+ }
+
+ return new RoutingRule(HostMatchKind.GeoSite, $"{MatchString}@cn", outboundTag);
+ }
+
+ public RoutingRuleMatcher GetMatcher() => new(MatchKind, MatchString);
+
+ public V4ConfigJsonObjects.RoutingRule ToJsonObjectV4() => ListToJsonObject([this]);
+
+ object IV4ConfigObject.ToJsonObjectV4() => ToJsonObjectV4();
+}
+
+public record Routing(List<RoutingRule> Rules) : IV4ConfigObject, ISingConfigObject
+{
+ public List<RoutingRule> CreateGeositeCnDirectRules()
+ {
+ return Rules.Where(r => r.MatchKind is HostMatchKind.GeoSite)
+ .Select(r => r.CloneGeositeWithCnAttribute("direct")).ToList();
+ }
+
+ public SingConfigJsonObjects.Route ToJsonObjectSing()
+ {
+ List<SingConfigJsonObjects.RouteRule> ruleJsonObjects = [ new SingConfigJsonObjects.RouteRule(Outbound: "dns-out", Protocol: "dns")];
+ ruleJsonObjects.AddRange(RoutingRule.GroupByOutboundTag(Rules).Values.Select(RoutingRule.ListToJsonObjectSing));
+ return new SingConfigJsonObjects.Route(ruleJsonObjects);
+ }
+
+ public V4ConfigJsonObjects.Routing ToJsonObjectV4(string domainStrategy = "IpOnDemand", bool directGeositeCn = true)
+ {
+ List<V4ConfigJsonObjects.RoutingRule> ruleJsonObjects = [];
+
+ if (directGeositeCn)
+ {
+ ruleJsonObjects.Add(RoutingRule.ListToJsonObject(CreateGeositeCnDirectRules()));
+ }
+
+ ruleJsonObjects.AddRange(RoutingRule.GroupByOutboundTagAndMatcherKind(Rules).Select(RoutingRule.ListToJsonObject));
+
+ return new V4ConfigJsonObjects.Routing(ruleJsonObjects, domainStrategy);
+ }
+
+ object IV4ConfigObject.ToJsonObjectV4() => ToJsonObjectV4();
+
+ object ISingConfigObject.ToJsonObjectSing() => ToJsonObjectSing();
+
+ public static Routing FromProxyFile(ProxyFile proxyFile, string outboundTag)
+ {
+ return new Routing(
+ proxyFile.RoutingRuleMatchers.Select(m => m.ToRoutingRule(outboundTag)).ToList());
+ }
+
+ public static Routing FromProxyFileForSing(ProxyFile proxyFile, GeoSiteData geoSiteData, string outboundTag, string? directCnOutboundTag = null)
+ {
+ List<RoutingRule> rules = [];
+
+ if (directCnOutboundTag is not null)
+ {
+ rules.AddRange(proxyFile.GetChinaRulesByGeoSite(geoSiteData).Select(m => m.ToRoutingRule(directCnOutboundTag)).ToList());
+ }
+
+ rules.AddRange(proxyFile.GetRulesFlattenGeoSite(geoSiteData).Where(m => m.MatchKind.IsSupportedInSingRoute()).Select(m => m.ToRoutingRule(outboundTag)).ToList());
+
+ return new Routing(
+ rules
+ );
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs
new file mode 100644
index 0000000..56b5563
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs
@@ -0,0 +1,20 @@
+namespace Crupest.SecretTool;
+
+public static class SingConfigJsonObjects
+{
+ public interface IObject;
+
+ public record OutboundTls(bool Enabled);
+ public record V2rayTransportBase(string Type);
+ public record V2rayWebsocketTransport(string Path, Dictionary<string, string>? Headers = null) : V2rayTransportBase("ws");
+ public record OutboundBase(string Tag, string Type) : IObject;
+ public record VmessOutbound(string Tag, string Server, int ServerPort, string Uuid, string Security = "auto",
+ V2rayTransportBase? Transport = null, OutboundTls? Tls = null): OutboundBase(Tag, "vmess");
+
+ public record RouteRule(List<string>? Domain = null, List<string>? DomainSuffix = null, List<string>? DomainKeyword = null,
+ List<string>? DomainRegex = null, List<string>? IpCidr = null, List<string>? SourceIpCidr = null, string? Protocol = null,
+ List<int>? Port = null, List<int>? SourcePort = null, List<string>? PortRange = null, List<string>? SourcePortRange = null,
+ string? Network = null, List<string>? Inbound = null, string? Outbound = null) : IObject;
+
+ public record Route(List<RouteRule> Rules) : IObject;
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs
new file mode 100644
index 0000000..b112e1c
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs
@@ -0,0 +1,40 @@
+namespace Crupest.SecretTool;
+
+public record StaticHostRule(HostMatchKind MatchKind, string MatchString, List<string> ResolveResult)
+{
+ public string AddressString()
+ {
+ return MatchKind switch
+ {
+ HostMatchKind.DomainFull => MatchString,
+ HostMatchKind.DomainSuffix => $"domain:{MatchString}",
+ HostMatchKind.DomainKeyword => $"keyword:{MatchString}",
+ HostMatchKind.DomainRegex => $"regexp:{MatchString}",
+ _ => throw new ArgumentOutOfRangeException($"Match kind {MatchKind} is not allowed in static host rule."),
+ };
+ }
+
+ public object ResolveResultToJsonObject()
+ {
+ return ResolveResult.Count == 1 ? ResolveResult[0] : ResolveResult;
+ }
+}
+
+public class StaticHosts(List<StaticHostRule> rules) : IV4ConfigObject
+{
+ public List<StaticHostRule> Rules { get; } = rules;
+
+ public Dictionary<string, object> ToJsonObjectV4() =>
+ Rules.ToDictionary(rule => rule.AddressString(), rule => rule.ResolveResultToJsonObject());
+
+ object IV4ConfigObject.ToJsonObjectV4()
+ {
+ return ToJsonObjectV4();
+ }
+
+ public static StaticHosts CreateFromHostMatchConfigString(string configString)
+ {
+ var config = new HostMatchConfig(configString, HostMatchKindExtensions.DomainMatchKinds, minComponentCount: 1);
+ return new StaticHosts(config.Items.Select(i => new StaticHostRule(i.Kind, i.MatchString, [.. i.Values])).ToList());
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs
new file mode 100644
index 0000000..8a57c9f
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs
@@ -0,0 +1,56 @@
+namespace Crupest.SecretTool;
+
+public class SurgeConfigGenerator(ProxyFile proxyFile, GeoSiteData geoData)
+{
+ public ProxyFile ProxyFile => proxyFile;
+ public GeoSiteData GeoData => geoData;
+
+ private static string ToSurgeRuleString(HostMatchKind kind, string value)
+ {
+ var ruleType = kind switch
+ {
+ HostMatchKind.DomainFull => "DOMAIN",
+ HostMatchKind.DomainSuffix => "DOMAIN-SUFFIX",
+ HostMatchKind.DomainKeyword => "DOMAIN-KEYWORD",
+ HostMatchKind.DomainRegex => "URL-REGEX",
+ _ => throw new Exception("Unacceptable matcher kind for Surge rule.")
+ };
+
+ return $"{ruleType},{value}";
+ }
+
+ public static string GenerateSurgeRuleSetString(List<RoutingRuleMatcher> rules)
+ {
+ return string.Join('\n', rules.Select(r => ToSurgeRuleString(r.MatchKind, r.MatchString)));
+ }
+
+ public string GenerateChinaRuleSet()
+ {
+ return GenerateSurgeRuleSetString(proxyFile.GetChinaRulesByGeoSite(GeoData));
+ }
+
+ public string GenerateGlobalRuleSet()
+ {
+ return GenerateSurgeRuleSetString(proxyFile.GetRulesFlattenGeoSite(geoData, true));
+ }
+
+ public static void GenerateTo(ProxyFile proxyFile, GeoSiteData geoSiteData, string cnPath, string globalPath, bool silent)
+ {
+ var generator = new SurgeConfigGenerator(proxyFile, geoSiteData);
+ File.WriteAllText(cnPath, generator.GenerateChinaRuleSet());
+ if (!silent) Console.WriteLine($"China rule set written to {cnPath}.");
+ File.WriteAllText(globalPath, generator.GenerateGlobalRuleSet());
+ if (!silent) Console.WriteLine($"Global rule set written to {globalPath}.");
+ }
+
+ public static void GenerateTo(string directory, string cnPath, string globalPath, bool clean, bool silent)
+ {
+ var geoSiteData = GeoDataManager.Instance.GetOrCreateGeoSiteData(clean, silent);
+ var proxyFile = new ProxyFile(Path.Combine(directory, ToolConfig.ProxyConfigFileName));
+ var generator = new SurgeConfigGenerator(proxyFile, geoSiteData);
+ File.WriteAllText(cnPath, generator.GenerateChinaRuleSet());
+ if (!silent) Console.WriteLine($"China rule set written to {cnPath}.");
+ File.WriteAllText(globalPath, generator.GenerateGlobalRuleSet());
+ if (!silent) Console.WriteLine($"Global rule set written to {globalPath}.");
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Template.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Template.cs
new file mode 100644
index 0000000..1fe91b1
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Template.cs
@@ -0,0 +1,231 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+namespace Crupest.SecretTool;
+
+public class Template
+{
+ private enum ParseState
+ {
+ Text,
+ Dollar,
+ LeftBracket,
+ VariableName,
+ VariableNameFinish,
+ }
+
+ private interface ITemplateNode
+ {
+ string Render(Dictionary<string, string> values);
+ }
+
+ private class TextNode(string text) : ITemplateNode
+ {
+
+ public string Text { get; } = text;
+
+ public string Render(Dictionary<string, string> values)
+ {
+ return Text;
+ }
+ }
+
+ private class VariableNode(string variableName) : ITemplateNode
+ {
+ public string VariableName { get; } = variableName;
+
+ public string Render(Dictionary<string, string> values)
+ {
+ return values.GetValueOrDefault(VariableName) ?? "";
+ }
+ }
+
+ public Template(string templateString)
+ {
+ TemplateString = templateString;
+ Nodes = Parse(templateString);
+ VariableNames = Nodes.OfType<VariableNode>().Select(node => node.VariableName).ToList();
+ }
+
+ private static List<ITemplateNode> Parse(string templateString)
+ {
+ int lineNumber = 1;
+ int columnNumber = 0;
+ List<ITemplateNode> nodes = [];
+ ParseState state = ParseState.Text;
+ StringBuilder stringBuilder = new();
+
+ string GetPosition() => $"line {lineNumber} column{columnNumber}";
+
+ [DoesNotReturn]
+ void ReportInvalidState(string message)
+ {
+ throw new Exception($"Invalid state at {GetPosition()}: {message}");
+ }
+
+ [DoesNotReturn]
+ void ReportInvalidCharacter(char c)
+ {
+ throw new FormatException($"Unexpected '{c}' at {GetPosition()}.");
+ }
+
+ void FinishText()
+ {
+ if (state != ParseState.Text)
+ {
+ ReportInvalidState($"Can't call FinishText here.");
+ }
+
+ if (stringBuilder.Length > 0)
+ {
+ nodes.Add(new TextNode(stringBuilder.ToString()));
+ stringBuilder.Clear();
+ }
+ }
+
+ foreach (var c in templateString)
+ {
+ if (c == '\n')
+ {
+ lineNumber++;
+ columnNumber = 0;
+ }
+
+ columnNumber++;
+
+ switch (c)
+ {
+ case '$':
+ if (state == ParseState.Text)
+ {
+ FinishText();
+ state = ParseState.Dollar;
+ }
+ else if (state == ParseState.Dollar)
+ {
+ if (stringBuilder.Length > 0)
+ {
+ throw new Exception($"Invalid state at {GetPosition()}: when we meet the second '$', text builder should be empty.");
+ }
+ stringBuilder.Append(c);
+ state = ParseState.Text;
+ }
+ else
+ {
+ throw new FormatException($"Unexpected '$' at {GetPosition()}.");
+ }
+ break;
+ case '{':
+ if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ }
+ else if (state == ParseState.Dollar)
+ {
+ state = ParseState.LeftBracket;
+ }
+ else
+ {
+ throw new Exception($"Unexpected '{{' at {GetPosition()}.");
+ }
+ break;
+ case '}':
+ if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ state = ParseState.Text;
+ }
+ else if (state == ParseState.VariableName || state == ParseState.VariableNameFinish)
+ {
+ nodes.Add(new VariableNode(stringBuilder.ToString()));
+ stringBuilder.Clear();
+ state = ParseState.Text;
+ }
+ else
+ {
+ ReportInvalidCharacter(c);
+ }
+ break;
+ default:
+ if (state == ParseState.Dollar)
+ {
+ ReportInvalidCharacter(c);
+ }
+
+ if (char.IsWhiteSpace(c))
+ {
+ if (state == ParseState.LeftBracket || state == ParseState.VariableNameFinish)
+ {
+ continue;
+ }
+ else if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ }
+ else if (state == ParseState.VariableName)
+ {
+ state = ParseState.VariableNameFinish;
+ }
+ else
+ {
+ ReportInvalidCharacter(c);
+ }
+ }
+ else
+ {
+ if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ }
+ else if (state == ParseState.LeftBracket || state == ParseState.VariableName)
+ {
+ stringBuilder.Append(c);
+ state = ParseState.VariableName;
+ }
+ else
+ {
+ ReportInvalidCharacter(c);
+ }
+ }
+ break;
+ }
+ }
+
+ if (state == ParseState.Text)
+ {
+ FinishText();
+ }
+ else
+ {
+ throw new FormatException("Unexpected end of template string.");
+ }
+
+ return nodes;
+ }
+
+ public string TemplateString { get; }
+ private List<ITemplateNode> Nodes { get; set; }
+ public List<string> VariableNames { get; }
+
+ public string Generate(Dictionary<string, string> values, bool allowMissingVariable = false)
+ {
+ StringBuilder stringBuilder = new();
+ foreach (var node in Nodes)
+ {
+ if (node is TextNode textNode)
+ {
+ stringBuilder.Append(textNode.Text);
+ }
+ else if (node is VariableNode variableNode)
+ {
+ var hasValue = values.TryGetValue(variableNode.VariableName, out var value);
+ if (!hasValue && !allowMissingVariable)
+ {
+ throw new Exception($"Variable '{variableNode.VariableName}' is not set.");
+ }
+ stringBuilder.Append(hasValue ? value : string.Empty);
+ }
+ }
+ return stringBuilder.ToString();
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs
new file mode 100644
index 0000000..809fba1
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs
@@ -0,0 +1,271 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Crupest.SecretTool;
+
+public interface IV4ConfigObject
+{
+ object ToJsonObjectV4();
+}
+
+public interface ISingConfigObject
+{
+ object ToJsonObjectSing();
+}
+
+public class ToolConfigBase(Template template, List<Proxy> proxies, Routing router)
+{
+ protected class JsonInterfaceConverter<Interface> : JsonConverter<Interface>
+ {
+ public override Interface Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ Interface value,
+ JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value, typeof(object), options);
+ }
+ }
+
+ public const string VmessConfigFileName = "vmess.txt";
+ public const string ProxyConfigFileName = "proxy.txt";
+
+ public Template Template { get; set; } = template;
+ public List<Proxy> Proxies { get; set; } = proxies;
+ public Routing Routing { get; set; } = router;
+}
+
+public class ToolConfig(Template template, List<Proxy> proxies, Routing router, StaticHosts? hosts) : ToolConfigBase(template, proxies, router)
+{
+ public const string ConfigTemplateFileName = "config.json.template";
+ public const string HostsConfigFileName = "hosts.txt";
+
+ public static List<string> RequiredConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName];
+ public static List<string> ConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName, HostsConfigFileName];
+
+ private const string ProxyAnchor = "PROXY_ANCHOR";
+ private const string RoutingAnchor = "ROUTING_ANCHOR";
+ private const string HostsAnchor = "HOSTS_ANCHOR";
+
+ public const string AddCnAttributeToGeositeEnvironmentVariable = "CRUPEST_V2RAY_GEOSITE_USE_CN";
+
+ private static bool UseCnGeoSite => Environment.GetEnvironmentVariable(AddCnAttributeToGeositeEnvironmentVariable) switch
+ {
+ "0" or "false" or "off" or "disable" => false,
+ _ => true
+ };
+
+ public StaticHosts Hosts { get; set; } = hosts is null ? new StaticHosts([]) : hosts;
+
+ public string ToJsonStringV4(string domainStrategy = "IpOnDemand", bool directGeositeCn = true, bool pretty = true)
+ {
+ var jsonOptions = new JsonSerializerOptions(new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ });
+ // TODO: Make interface converter generic.
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<V4ConfigJsonObjects.IOutboundSettings>());
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<V4ConfigJsonObjects.IOutboundStreamSettings>());
+
+ var templateValues = new Dictionary<string, string>
+ {
+ [ProxyAnchor] = string.Join(',', Proxies.Select(p => JsonSerializer.Serialize(p.ToJsonObjectV4(), jsonOptions))),
+ [RoutingAnchor] = JsonSerializer.Serialize(Routing.ToJsonObjectV4(domainStrategy, directGeositeCn), jsonOptions),
+ [HostsAnchor] = JsonSerializer.Serialize(Hosts.ToJsonObjectV4(), jsonOptions),
+ };
+
+ var configString = Template.Generate(templateValues);
+
+ if (pretty)
+ {
+ var jsonOptionsPretty = new JsonSerializerOptions(jsonOptions)
+ {
+ WriteIndented = true,
+ };
+ return JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(configString, jsonOptionsPretty), jsonOptionsPretty);
+ }
+ else
+ {
+ return configString;
+ }
+ }
+
+ public static ToolConfig FromFiles(string templatePath, string vmessPath, string proxyPath, string? hostsPath)
+ {
+ foreach (var path in new List<string>([templatePath, vmessPath, proxyPath]))
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"Required config file not found: {path}.");
+ }
+ }
+
+ ProxyFile proxyFile = new(proxyPath);
+ string templateString, vmessString;
+ string? hostsString;
+
+ string file = "";
+ try
+ {
+ file = templatePath;
+ templateString = File.ReadAllText(templatePath);
+ file = vmessPath;
+ vmessString = File.ReadAllText(vmessPath);
+ hostsString = hostsPath is not null ? File.ReadAllText(hostsPath) : null;
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error reading config file {file}.", e);
+ }
+
+ try
+ {
+ file = templatePath;
+ var template = new Template(templateString);
+ file = vmessPath;
+ var vmess = VmessProxy.CreateFromConfigString(vmessString, "proxy");
+ file = proxyPath;
+ var routing = Routing.FromProxyFile(proxyFile, "proxy");
+ file = hostsPath ?? "";
+ var hosts = hostsString is not null ? StaticHosts.CreateFromHostMatchConfigString(hostsString) : null;
+ return new ToolConfig(template, [vmess], routing, hosts);
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error parsing config file {file}.", e);
+ }
+ }
+
+ public static ToolConfig FromDirectory(string directory)
+ {
+ return FromFiles(
+ Path.Join(directory, ConfigTemplateFileName),
+ Path.Join(directory, VmessConfigFileName),
+ Path.Join(directory, ProxyConfigFileName),
+ Path.Join(directory, HostsConfigFileName)
+ );
+ }
+
+ public static void FromDirectoryAndWriteToFile(string directory, string outputPath)
+ {
+ var config = FromDirectory(directory);
+ File.WriteAllText(outputPath, config.ToJsonStringV4());
+ }
+}
+
+public class SingToolConfig(Template template, List<Proxy> proxies, Routing router, string inboundsString) : ToolConfigBase(template, proxies, router)
+{
+
+ public const string ConfigTemplateFileName = "sing-config.json.template";
+ public const string ConfigInboundsPcFileName = "sing-inbounds-pc.json";
+ public const string ConfigInboundsMobileFileName = "sing-inbounds-mobile.json";
+
+ public static List<string> RequiredConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName, ConfigInboundsMobileFileName, ConfigInboundsPcFileName];
+
+ private const string ProxyAnchor = "PROXY_ANCHOR";
+ private const string RouteAnchor = "ROUTE_ANCHOR";
+ private const string InboundsAnchor = "INBOUNDS_ANCHOR";
+
+ public string InboundsString { get; } = inboundsString;
+
+ public string ToSingConfigString(bool pretty = true)
+ {
+ var jsonOptions = new JsonSerializerOptions(new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ });
+ // TODO: Make interface converter generic.
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<SingConfigJsonObjects.OutboundBase>());
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<SingConfigJsonObjects.V2rayTransportBase>());
+
+ var templateValues = new Dictionary<string, string>
+ {
+ [ProxyAnchor] = string.Join(',', Proxies.Select(p => JsonSerializer.Serialize(p.ToJsonObjectSing(), jsonOptions))),
+ [RouteAnchor] = JsonSerializer.Serialize(Routing.ToJsonObjectSing(), jsonOptions),
+ [InboundsAnchor] = InboundsString
+ };
+
+ var configString = Template.Generate(templateValues);
+
+ if (pretty)
+ {
+ var jsonOptionsPretty = new JsonSerializerOptions(jsonOptions)
+ {
+ WriteIndented = true,
+ };
+ return JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(configString, jsonOptionsPretty), jsonOptionsPretty);
+ }
+ else
+ {
+ return configString;
+ }
+ }
+
+ public static SingToolConfig FromFiles(string templatePath, string vmessPath, string proxyPath, string inboundsPath, bool clean, bool silent)
+ {
+ foreach (var path in new List<string>([templatePath, vmessPath, proxyPath, inboundsPath]))
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"Required config file not found: {path}.");
+ }
+ }
+
+ var geoSiteData = GeoDataManager.Instance.GetOrCreateGeoSiteData(clean, silent);
+
+ ProxyFile proxyFile = new(proxyPath);
+ string templateString, vmessString, inboundsString;
+
+ string file = "";
+ try
+ {
+ file = templatePath;
+ templateString = File.ReadAllText(templatePath);
+ file = vmessPath;
+ vmessString = File.ReadAllText(vmessPath);
+ file = inboundsPath;
+ inboundsString = File.ReadAllText(inboundsPath);
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error reading config file {file}.", e);
+ }
+
+ try
+ {
+ file = templatePath;
+ var template = new Template(templateString);
+ file = vmessPath;
+ var vmess = VmessProxy.CreateFromConfigString(vmessString, "proxy-out");
+ file = proxyPath;
+ var routing = Routing.FromProxyFileForSing(proxyFile, geoSiteData, "proxy-out", "direct-out");
+ return new SingToolConfig(template, [vmess], routing, inboundsString);
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error parsing config file {file}.", e);
+ }
+ }
+
+ public static SingToolConfig FromDirectory(string directory, bool isMobile, bool clean, bool silent)
+ {
+ return FromFiles(
+ Path.Join(directory, ConfigTemplateFileName),
+ Path.Join(directory, VmessConfigFileName),
+ Path.Join(directory, ProxyConfigFileName),
+ isMobile ? Path.Join(directory, ConfigInboundsMobileFileName) : Path.Join(directory, ConfigInboundsPcFileName),
+ clean, silent
+ );
+ }
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs
new file mode 100644
index 0000000..3e81dbb
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs
@@ -0,0 +1,25 @@
+namespace Crupest.SecretTool;
+
+public static class V4ConfigJsonObjects
+{
+ public interface IObject;
+ public interface IOutboundSettings : IObject;
+ public interface IOutboundStreamSettings : IObject;
+
+ public record WsSettings(string Path, Dictionary<string, string> Headers) : IObject;
+ public record WsStreamSettings(string Network, string Security, WsSettings WsSettings) : IOutboundStreamSettings;
+ public record VnextServerUser(string Id, int AlterId, string Security, int Level) : IObject;
+ public record VnextServer(string Address, int Port, List<VnextServerUser> Users) : IObject;
+ public record VmessOutboundSettings(List<VnextServer> Vnext) : IOutboundSettings;
+ public record HttpOutboundUser(string User, string Pass) : IObject;
+ public record HttpOutboundServer(string Address, int Port, List<HttpOutboundUser> Users) : IObject;
+ public record HttpOutboundSettings(List<HttpOutboundServer> Servers) : IOutboundSettings;
+ public record Outbound(string Tag, string Protocol, IOutboundSettings Settings,
+ IOutboundStreamSettings? StreamSettings) : IObject;
+
+ public record RoutingRule(string DomainMatcher = "mph", string Type = "field", List<string>? Domains = null, List<string>? Ip = null,
+ string? Port = null, string? SourcePort = null, string? Network = null, List<string>? Source = null,
+ List<string>? User = null, List<string>? InboundTag = null, List<string>? Protocol = null, string? Attrs = null,
+ string? OutboundTag = null, string? BalancerTag = null) : IObject;
+ public record Routing(List<RoutingRule> Rules, string DomainStrategy = "IpOnDemand", string DomainMatcher = "mph") : IObject;
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs
new file mode 100644
index 0000000..a50e9be
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs
@@ -0,0 +1,31 @@
+namespace Crupest.SecretTool;
+
+public static class V5ConfigJsonObjects
+{
+ public record OutboundObject(string Protocol, object Settings, string Tag, object? StreamSettings)
+ {
+ public static OutboundObject VmessViaWs(string tag, string address, int port, string uuid, string path)
+ {
+ return new OutboundObject("vmess", new VmessSettings(address, port, uuid), tag, StreamSettingsObject.Ws(path));
+ }
+
+ public static OutboundObject Http(string tag, string address, int port)
+ {
+ return new OutboundObject("http", new HttpSettingsObject(address, port), tag, null);
+ }
+ }
+
+ public record WsSettingsObject(string Path, Dictionary<string, string> Headers);
+
+ public record StreamSettingsObject(string Transport, object TransportSettings, string Security, object SecuritySettings)
+ {
+ public static StreamSettingsObject Ws(string path)
+ {
+ return new StreamSettingsObject("ws", new WsSettingsObject(path, new()), "tls", new());
+ }
+ }
+
+ public record VmessSettings(string Address, int Port, string Uuid);
+
+ public record HttpSettingsObject(string Address, int Port);
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/config.json.template b/tools/Crupest.SecretTool/Crupest.SecretTool/config.json.template
new file mode 100644
index 0000000..424e996
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/config.json.template
@@ -0,0 +1,63 @@
+{
+ "log": {
+ "loglevel": "warning"
+ },
+ "inbounds": [
+ {
+ "port": 2081,
+ "listen": "127.0.0.1",
+ "tag": "socks-inbound",
+ "protocol": "socks",
+ "settings": {
+ "auth": "noauth"
+ }
+ },
+ {
+ "port": 2080,
+ "listen": "127.0.0.1",
+ "tag": "http-inbound",
+ "protocol": "http",
+ "settings": {
+ "auth": "noauth"
+ }
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "settings": {},
+ "tag": "direct"
+ },
+ {
+ "protocol": "blackhole",
+ "settings": {},
+ "tag": "blocked"
+ },
+ ${PROXY_ANCHOR}
+ ],
+ "routing": ${ROUTING_ANCHOR},
+ "dns": {
+ "hosts": ${HOSTS_ANCHOR},
+ "servers": [
+ "https://doh.pub/dns-query",
+ "1.1.1.1",
+ "8.8.8.8",
+ "localhost"
+ ]
+ },
+ "policy": {
+ "levels": {
+ "0": {
+ "uplinkOnly": 0,
+ "downlinkOnly": 0
+ }
+ },
+ "system": {
+ "statsInboundUplink": false,
+ "statsInboundDownlink": false,
+ "statsOutboundUplink": false,
+ "statsOutboundDownlink": false
+ }
+ },
+ "other": {}
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template b/tools/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template
new file mode 100644
index 0000000..01ccf7a
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template
@@ -0,0 +1,55 @@
+{
+ "log": {
+ "access": {
+ "type": "Console",
+ "level": "Info"
+ }
+ },
+ "dns": {
+ "nameServer": [{
+ "address": "https://doh.pub/dns-query"
+ }, {
+ "address": "1.1.1.1"
+ }, {
+ "address": "8.8.8.8"
+ }, {
+ "address": "localhost"
+ }],
+ "staticHosts": ${HOSTS_ANCHOR}
+ },
+ "inbounds": [{
+ {
+ "protocol": "socks",
+ "port": 2081,
+ "listen": "127.0.0.1",
+ "tag": "socks-inbound",
+ "settings": {
+ "auth": "noauth"
+ }
+ },
+ {
+ "protocol": "http",
+ "port": 2080,
+ "listen": "127.0.0.1",
+ "tag": "http-inbound",
+ "settings": {
+ "auth": "noauth"
+ }
+ }
+ }],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "settings": {},
+ "tag": "direct"
+ },
+ {
+ "protocol": "blackhole",
+ "settings": {},
+ "tag": "blocked"
+ },
+ ${PROXY_ANCHOR}
+ ],
+ "router": ${ROUTER_ANCHOR}
+}
+
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/hosts.txt b/tools/Crupest.SecretTool/Crupest.SecretTool/hosts.txt
new file mode 100644
index 0000000..88d5015
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/hosts.txt
@@ -0,0 +1,2 @@
+cdn.jsdelivr.net cdn.jsdelivr.net.cdn.cloudflare.net
+
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/proxy.txt b/tools/Crupest.SecretTool/Crupest.SecretTool/proxy.txt
new file mode 100644
index 0000000..39800f9
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/proxy.txt
@@ -0,0 +1,50 @@
+GeoSite microsoft
+GeoSite google
+GeoSite youtube
+GeoSite x
+GeoSite facebook
+GeoSite discord
+GeoSite reddit
+GeoSite twitch
+GeoSite quora
+GeoSite telegram
+GeoSite imgur
+GeoSite stackexchange
+GeoSite medium
+
+GeoSite duckduckgo
+GeoSite wikimedia
+GeoSite gitbook
+GeoSite github
+GeoSite gitlab
+GeoSite sourceforge
+GeoSite creativecommons
+GeoSite archive
+GeoSite matrix
+GeoSite tor
+
+GeoSite python
+GeoSite ruby
+GeoSite rust
+GeoSite nodejs
+GeoSite npmjs
+GeoSite qt
+GeoSite docker
+GeoSite v2ray
+GeoSite homebrew
+
+GeoSite azure
+GeoSite akamai
+GeoSite aws
+GeoSite jsdelivr
+GeoSite fastly
+GeoSite heroku
+GeoSite bootstrap
+GeoSite vercel
+
+GeoSite ieee
+GeoSite sci-hub
+GeoSite libgen
+GeoSite z-library
+
+sagernet.org
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template b/tools/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template
new file mode 100644
index 0000000..d7e55a0
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template
@@ -0,0 +1,45 @@
+{
+ "log": {
+ "disabled": false,
+ "level": "info",
+ "timestamp": true
+ },
+ "dns": {
+ "servers": [
+ {
+ "tag": "ali-doh",
+ "address": "https://dns.alidns.com/dns-query",
+ "address_resolver": "ali"
+ },
+ {
+ "tag": "ali",
+ "address": "223.5.5.5"
+ },
+ {
+ "tag": "cloudflare",
+ "address": "1.1.1.1"
+ },
+ {
+ "tag": "google",
+ "address": "8.8.8.8"
+ }
+ ]
+ },
+ "inbounds": ${INBOUNDS_ANCHOR},
+ "outbounds": [
+ {
+ "type": "direct",
+ "tag": "direct-out"
+ },
+ {
+ "type": "block",
+ "tag": "block-out"
+ },
+ {
+ "tag": "dns-out",
+ "type": "dns"
+ },
+ ${PROXY_ANCHOR}
+ ],
+ "route": ${ROUTE_ANCHOR}
+}
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json b/tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json
new file mode 100644
index 0000000..5038c40
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json
@@ -0,0 +1,11 @@
+[
+ {
+ "tag": "tun-in",
+ "type": "tun",
+ "auto_route": true,
+ "strict_route": true,
+ "address": [ "172.23.0.1/30", "fdfe:acbd:9876::1/126"],
+ "sniff": true,
+ "sniff_override_destination": true
+ }
+]
diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json b/tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json
new file mode 100644
index 0000000..956d751
--- /dev/null
+++ b/tools/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json
@@ -0,0 +1,14 @@
+[
+ {
+ "tag": "http-in",
+ "type": "http",
+ "listen": "127.0.0.1",
+ "listen_port": 3080
+ },
+ {
+ "tag": "socks-in",
+ "type": "socks",
+ "listen": "127.0.0.1",
+ "listen_port": 3081
+ }
+] \ No newline at end of file
diff --git a/tools/Crupest.SecretTool/build-secret.bash b/tools/Crupest.SecretTool/build-secret.bash
new file mode 100755
index 0000000..8878049
--- /dev/null
+++ b/tools/Crupest.SecretTool/build-secret.bash
@@ -0,0 +1,41 @@
+#! /usr/bin/env bash
+
+set -e
+
+function print_argument_error_message_and_exit() {
+ argument_error_message="You must specify exactly one argument, the build target (win-x64 | linux-x64 | osx-x64)."
+ echo "$argument_error_message"
+ exit 1
+}
+
+
+
+if [[ $# != 1 ]]; then
+ print_argument_error_message_and_exit
+fi
+
+case "$1" in
+ win-x64 | linux-x64 | osx-x64)
+ echo "Build target: $1"
+ ;;
+ *)
+ print_argument_error_message_and_exit
+ ;;
+esac
+
+secret_dir=$(realpath "$(dirname "$0")")
+
+echo "Secret dir: ${secret_dir}"
+
+echo "Check dotnet..."
+dotnet --version
+
+echo "Enter \"secret\" dir..."
+pushd "$secret_dir"
+
+echo "Begin to build..."
+dotnet publish Crupest.SecretTool -c Release -o "$secret_dir/publish" --sc -r "$1"
+
+popd
+
+echo "Finish!"
diff --git a/tools/Crupest.SecretTool/build-secret.ps1 b/tools/Crupest.SecretTool/build-secret.ps1
new file mode 100644
index 0000000..8aa7987
--- /dev/null
+++ b/tools/Crupest.SecretTool/build-secret.ps1
@@ -0,0 +1,25 @@
+if ($args.Count -ne 1 || $args[0] -notmatch "^win-x64|linux-x64|osx-x64$")
+{
+ Write-Error "You must specify exactly one argument, the build target (win-x64 | linux-x64 | osx-x64)."
+ exit 1
+}
+
+Write-Output "Secret dir: $PSScriptRoot"
+
+Write-Output "Check dotnet..."
+dotnet --version
+if ($LASTEXITCODE -ne 0)
+{
+ Write-Error "dotnet not found."
+ exit 2
+}
+
+Write-Output "Enter `"secret`" dir..."
+Push-Location $PSScriptRoot
+
+Write-Output "Begin to build..."
+dotnet publish Crupest.SecretTool -c Release -o "$secret_dir/publish" --sc -r $args[0]
+
+Pop-Location
+
+Write-Host "Finish!" -ForegroundColor Green
diff --git a/tools/Crupest.SecretTool/tools/cru-proxy-edit b/tools/Crupest.SecretTool/tools/cru-proxy-edit
new file mode 100755
index 0000000..51a33e1
--- /dev/null
+++ b/tools/Crupest.SecretTool/tools/cru-proxy-edit
@@ -0,0 +1,12 @@
+#! /usr/bin/env bash
+
+set -e
+
+p="$HOME/codes/crupest/tools/Crupest.SecretTool/publish/proxy.txt"
+
+if [[ ! -f "$p" ]]; then
+ echo "File $p does not exist!" >&2
+ exit 1
+fi
+
+exec vim "$p"
diff --git a/tools/Crupest.SecretTool/tools/cru-proxy-log b/tools/Crupest.SecretTool/tools/cru-proxy-log
new file mode 100755
index 0000000..6ec6ee1
--- /dev/null
+++ b/tools/Crupest.SecretTool/tools/cru-proxy-log
@@ -0,0 +1,13 @@
+#! /usr/bin/env bash
+
+set -e
+
+if [[ -e /proc ]]; then
+ # I don't believe your system is Linux but there is no /proc.
+ exec journalctl --user -u crupest-secret-tool "$@"
+elif [[ "$(uname)" == "Darwin" ]]; then
+ exec less "$HOME/.local/state/Crupest.SecretTool/log"
+else
+ echo "Not supported on systems other than macOS and Linux now." >&2
+ exit 1
+fi
diff --git a/tools/Crupest.SecretTool/tools/crupest-secret-tool.service b/tools/Crupest.SecretTool/tools/crupest-secret-tool.service
new file mode 100644
index 0000000..df6d172
--- /dev/null
+++ b/tools/Crupest.SecretTool/tools/crupest-secret-tool.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=crupest v2ray service
+
+[Service]
+ExecStart=%h/.local/bin/Crupest.SecretTool
+
+[Install]
+WantedBy=default.target
diff --git a/tools/Crupest.SecretTool/tools/crupest-secret-tool.xml b/tools/Crupest.SecretTool/tools/crupest-secret-tool.xml
new file mode 100644
index 0000000..9b85f13
--- /dev/null
+++ b/tools/Crupest.SecretTool/tools/crupest-secret-tool.xml
@@ -0,0 +1,49 @@
+<!--
+ MIT License
+
+ Copyright (c) 2008-2020 Kohsuke Kawaguchi, Sun Microsystems, Inc., CloudBees,
+ Inc., Oleg Nenashev and other contributors
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+-->
+
+<!--
+ This is a sample configuration of the Windows Service Wrapper.
+ This configuration file should be placed near the WinSW executable, the name should be the same.
+ E.g. for myapp.exe the configuration file name should be myapp.xml
+
+ You can find more information about configuration options here:
+https://github.com/kohsuke/winsw/blob/master/doc/xmlConfigFile.md
+-->
+<service>
+ <id>crupest-secret-tool</id>
+ <name>Crupest Secret Tool</name>
+ <description>Crupest Secret Tool (powered by WinSW)</description>
+
+ <!-- Path to the executable, which should be started -->
+ <executable>%BASE%\Crupest.SecretTool.exe</executable>
+
+ <onfailure action="restart" delay="10 sec" />
+ <onfailure action="restart" delay="30 sec" />
+ <onfailure action="restart" delay="50 sec" />
+
+ <workingdirectory>%BASE%</workingdirectory>
+
+ <startmode>Automatic</startmode>
+</service> \ No newline at end of file
diff --git a/tools/Crupest.SecretTool/tools/life.crupest.secret-tool.plist b/tools/Crupest.SecretTool/tools/life.crupest.secret-tool.plist
new file mode 100644
index 0000000..bdfe490
--- /dev/null
+++ b/tools/Crupest.SecretTool/tools/life.crupest.secret-tool.plist
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>life.crupest.secret-tool</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/Users/crupest/.local/bin/Crupest.SecretTool</string>
+ </array>
+ <key>KeepAlive</key>
+ <true/>
+ <key>StandardOutPath</key>
+ <string>/Users/crupest/.local/state/Crupest.SecretTool/log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/crupest/.local/state/Crupest.SecretTool/error</string>
+</dict>
+</plist>
diff --git a/tools/cru-py/.gitignore b/tools/cru-py/.gitignore
new file mode 100644
index 0000000..9f7550b
--- /dev/null
+++ b/tools/cru-py/.gitignore
@@ -0,0 +1,2 @@
+__pycache__
+.venv
diff --git a/tools/cru-py/.python-version b/tools/cru-py/.python-version
new file mode 100644
index 0000000..37504c5
--- /dev/null
+++ b/tools/cru-py/.python-version
@@ -0,0 +1 @@
+3.11
diff --git a/tools/cru-py/cru/__init__.py b/tools/cru-py/cru/__init__.py
new file mode 100644
index 0000000..17799a9
--- /dev/null
+++ b/tools/cru-py/cru/__init__.py
@@ -0,0 +1,60 @@
+import sys
+
+from ._base import CRU, CruNamespaceError, CRU_NAME_PREFIXES
+from ._error import (
+ CruException,
+ CruLogicError,
+ CruInternalError,
+ CruUnreachableError,
+ cru_unreachable,
+)
+from ._const import (
+ CruConstantBase,
+ CruDontChange,
+ CruNotFound,
+ CruNoValue,
+ CruPlaceholder,
+ CruUseDefault,
+)
+from ._func import CruFunction
+from ._iter import CruIterable, CruIterator
+from ._event import CruEvent, CruEventHandlerToken
+from ._type import CruTypeSet, CruTypeCheckError
+
+
+class CruInitError(CruException):
+ pass
+
+
+def check_python_version(required_version=(3, 11)):
+ if sys.version_info < required_version:
+ raise CruInitError(f"Python version must be >= {required_version}!")
+
+
+check_python_version()
+
+__all__ = [
+ "CRU",
+ "CruNamespaceError",
+ "CRU_NAME_PREFIXES",
+ "check_python_version",
+ "CruException",
+ "CruInternalError",
+ "CruLogicError",
+ "CruUnreachableError",
+ "cru_unreachable",
+ "CruInitError",
+ "CruConstantBase",
+ "CruDontChange",
+ "CruNotFound",
+ "CruNoValue",
+ "CruPlaceholder",
+ "CruUseDefault",
+ "CruFunction",
+ "CruIterable",
+ "CruIterator",
+ "CruEvent",
+ "CruEventHandlerToken",
+ "CruTypeSet",
+ "CruTypeCheckError",
+]
diff --git a/tools/cru-py/cru/_base.py b/tools/cru-py/cru/_base.py
new file mode 100644
index 0000000..2599d8f
--- /dev/null
+++ b/tools/cru-py/cru/_base.py
@@ -0,0 +1,101 @@
+from typing import Any
+
+from ._helper import remove_none
+from ._error import CruException
+
+
+class CruNamespaceError(CruException):
+ """Raised when a namespace is not found."""
+
+
+class _Cru:
+ NAME_PREFIXES = ("CRU_", "Cru", "cru_")
+
+ def __init__(self) -> None:
+ self._d: dict[str, Any] = {}
+
+ def all_names(self) -> list[str]:
+ return list(self._d.keys())
+
+ def get(self, name: str) -> Any:
+ return self._d[name]
+
+ def has_name(self, name: str) -> bool:
+ return name in self._d
+
+ @staticmethod
+ def _maybe_remove_prefix(name: str) -> str | None:
+ for prefix in _Cru.NAME_PREFIXES:
+ if name.startswith(prefix):
+ return name[len(prefix) :]
+ return None
+
+ def _check_name_exist(self, *names: str | None) -> None:
+ for name in names:
+ if name is None:
+ continue
+ if self.has_name(name):
+ raise CruNamespaceError(f"Name {name} exists in CRU.")
+
+ @staticmethod
+ def check_name_format(name: str) -> tuple[str, str]:
+ no_prefix_name = _Cru._maybe_remove_prefix(name)
+ if no_prefix_name is None:
+ raise CruNamespaceError(
+ f"Name {name} is not prefixed with any of {_Cru.NAME_PREFIXES}."
+ )
+ return name, no_prefix_name
+
+ @staticmethod
+ def _check_object_name(o) -> tuple[str, str]:
+ return _Cru.check_name_format(o.__name__)
+
+ def _do_add(self, o, *names: str | None) -> list[str]:
+ name_list: list[str] = remove_none(names)
+ for name in name_list:
+ self._d[name] = o
+ return name_list
+
+ def add(self, o, name: str | None) -> tuple[str, str | None]:
+ no_prefix_name: str | None
+ if name is None:
+ name, no_prefix_name = self._check_object_name(o)
+ else:
+ no_prefix_name = self._maybe_remove_prefix(name)
+
+ self._check_name_exist(name, no_prefix_name)
+ self._do_add(o, name, no_prefix_name)
+ return name, no_prefix_name
+
+ def add_with_alias(self, o, name: str | None = None, *aliases: str) -> list[str]:
+ final_names: list[str | None] = []
+ no_prefix_name: str | None
+ if name is None:
+ name, no_prefix_name = self._check_object_name(o)
+ self._check_name_exist(name, no_prefix_name)
+ final_names.extend([name, no_prefix_name])
+ for alias in aliases:
+ no_prefix_name = self._maybe_remove_prefix(alias)
+ self._check_name_exist(alias, no_prefix_name)
+ final_names.extend([alias, no_prefix_name])
+
+ return self._do_add(o, *final_names)
+
+ def add_objects(self, *objects):
+ final_list = []
+ for o in objects:
+ name, no_prefix_name = self._check_object_name(o)
+ self._check_name_exist(name, no_prefix_name)
+ final_list.append((o, name, no_prefix_name))
+ for o, name, no_prefix_name in final_list:
+ self._do_add(o, name, no_prefix_name)
+
+ def __getitem__(self, item):
+ return self.get(item)
+
+ def __getattr__(self, item):
+ return self.get(item)
+
+
+CRU_NAME_PREFIXES = _Cru.NAME_PREFIXES
+CRU = _Cru()
diff --git a/tools/cru-py/cru/_const.py b/tools/cru-py/cru/_const.py
new file mode 100644
index 0000000..8246b35
--- /dev/null
+++ b/tools/cru-py/cru/_const.py
@@ -0,0 +1,49 @@
+from enum import Enum, auto
+from typing import Self, TypeGuard, TypeVar
+
+from ._base import CRU
+
+_T = TypeVar("_T")
+
+
+class CruConstantBase(Enum):
+ @classmethod
+ def check(cls, v: _T | Self) -> TypeGuard[Self]:
+ return isinstance(v, cls)
+
+ @classmethod
+ def check_not(cls, v: _T | Self) -> TypeGuard[_T]:
+ return not cls.check(v)
+
+ @classmethod
+ def value(cls) -> Self:
+ return cls.VALUE # type: ignore
+
+
+class CruNotFound(CruConstantBase):
+ VALUE = auto()
+
+
+class CruUseDefault(CruConstantBase):
+ VALUE = auto()
+
+
+class CruDontChange(CruConstantBase):
+ VALUE = auto()
+
+
+class CruNoValue(CruConstantBase):
+ VALUE = auto()
+
+
+class CruPlaceholder(CruConstantBase):
+ VALUE = auto()
+
+
+CRU.add_objects(
+ CruNotFound,
+ CruUseDefault,
+ CruDontChange,
+ CruNoValue,
+ CruPlaceholder,
+)
diff --git a/tools/cru-py/cru/_decorator.py b/tools/cru-py/cru/_decorator.py
new file mode 100644
index 0000000..137fc05
--- /dev/null
+++ b/tools/cru-py/cru/_decorator.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import (
+ Concatenate,
+ Generic,
+ ParamSpec,
+ TypeVar,
+ cast,
+)
+
+from ._base import CRU
+
+_P = ParamSpec("_P")
+_T = TypeVar("_T")
+_O = TypeVar("_O")
+_R = TypeVar("_R")
+
+
+class CruDecorator:
+
+ class ConvertResult(Generic[_T, _O]):
+ def __init__(
+ self,
+ converter: Callable[[_T], _O],
+ ) -> None:
+ self.converter = converter
+
+ def __call__(self, origin: Callable[_P, _T]) -> Callable[_P, _O]:
+ converter = self.converter
+
+ def real_impl(*args: _P.args, **kwargs: _P.kwargs) -> _O:
+ return converter(origin(*args, **kwargs))
+
+ return real_impl
+
+ class ImplementedBy(Generic[_T, _O, _P, _R]):
+ def __init__(
+ self,
+ impl: Callable[Concatenate[_O, _P], _R],
+ converter: Callable[[_T], _O],
+ ) -> None:
+ self.impl = impl
+ self.converter = converter
+
+ def __call__(
+ self, _origin: Callable[[_T], None]
+ ) -> Callable[Concatenate[_T, _P], _R]:
+ converter = self.converter
+ impl = self.impl
+
+ def real_impl(_self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R:
+ return cast(Callable[Concatenate[_O, _P], _R], impl)(
+ converter(_self), *args, **kwargs
+ )
+
+ return real_impl
+
+ @staticmethod
+ def create_factory(converter: Callable[[_T], _O]) -> Callable[
+ [Callable[Concatenate[_O, _P], _R]],
+ CruDecorator.ImplementedBy[_T, _O, _P, _R],
+ ]:
+ def create(
+ m: Callable[Concatenate[_O, _P], _R],
+ ) -> CruDecorator.ImplementedBy[_T, _O, _P, _R]:
+ return CruDecorator.ImplementedBy(m, converter)
+
+ return create
+
+ class ImplementedByNoSelf(Generic[_P, _R]):
+ def __init__(self, impl: Callable[_P, _R]) -> None:
+ self.impl = impl
+
+ def __call__(
+ self, _origin: Callable[[_T], None]
+ ) -> Callable[Concatenate[_T, _P], _R]:
+ impl = self.impl
+
+ def real_impl(_self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R:
+ return cast(Callable[_P, _R], impl)(*args, **kwargs)
+
+ return real_impl
+
+ @staticmethod
+ def create_factory() -> (
+ Callable[[Callable[_P, _R]], CruDecorator.ImplementedByNoSelf[_P, _R]]
+ ):
+ def create(
+ m: Callable[_P, _R],
+ ) -> CruDecorator.ImplementedByNoSelf[_P, _R]:
+ return CruDecorator.ImplementedByNoSelf(m)
+
+ return create
+
+
+CRU.add_objects(CruDecorator)
diff --git a/tools/cru-py/cru/_error.py b/tools/cru-py/cru/_error.py
new file mode 100644
index 0000000..e53c787
--- /dev/null
+++ b/tools/cru-py/cru/_error.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from typing import NoReturn, cast, overload
+
+
+class CruException(Exception):
+ """Base exception class of all exceptions in cru."""
+
+ @overload
+ def __init__(
+ self,
+ message: None = None,
+ *args,
+ user_message: str,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ message: str,
+ *args,
+ user_message: str | None = None,
+ **kwargs,
+ ): ...
+
+ def __init__(
+ self,
+ message: str | None = None,
+ *args,
+ user_message: str | None = None,
+ **kwargs,
+ ):
+ if message is None:
+ message = user_message
+
+ super().__init__(
+ message,
+ *args,
+ **kwargs,
+ )
+ self._message: str
+ self._message = cast(str, message)
+ self._user_message = user_message
+
+ @property
+ def message(self) -> str:
+ return self._message
+
+ def get_user_message(self) -> str | None:
+ return self._user_message
+
+ def get_message(self, use_user: bool = True) -> str:
+ if use_user and self._user_message is not None:
+ return self._user_message
+ else:
+ return self._message
+
+ @property
+ def is_internal(self) -> bool:
+ return False
+
+ @property
+ def is_logic_error(self) -> bool:
+ return False
+
+
+class CruLogicError(CruException):
+ """Raised when a logic error occurs."""
+
+ @property
+ def is_logic_error(self) -> bool:
+ return True
+
+
+class CruInternalError(CruException):
+ """Raised when an internal error occurs."""
+
+ @property
+ def is_internal(self) -> bool:
+ return True
+
+
+class CruUnreachableError(CruInternalError):
+ """Raised when a code path is unreachable."""
+
+
+def cru_unreachable() -> NoReturn:
+ raise CruUnreachableError("Code should not reach here!")
diff --git a/tools/cru-py/cru/_event.py b/tools/cru-py/cru/_event.py
new file mode 100644
index 0000000..51a794c
--- /dev/null
+++ b/tools/cru-py/cru/_event.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Generic, ParamSpec, TypeVar
+
+from .list import CruList
+
+_P = ParamSpec("_P")
+_R = TypeVar("_R")
+
+
+class CruEventHandlerToken(Generic[_P, _R]):
+ def __init__(
+ self, event: CruEvent, handler: Callable[_P, _R], once: bool = False
+ ) -> None:
+ self._event = event
+ self._handler = handler
+ self._once = once
+
+ @property
+ def event(self) -> CruEvent:
+ return self._event
+
+ @property
+ def handler(self) -> Callable[_P, _R]:
+ return self._handler
+
+ @property
+ def once(self) -> bool:
+ return self._once
+
+
+class CruEvent(Generic[_P, _R]):
+ def __init__(self, name: str) -> None:
+ self._name = name
+ self._tokens: CruList[CruEventHandlerToken] = CruList()
+
+ def register(
+ self, handler: Callable[_P, _R], once: bool = False
+ ) -> CruEventHandlerToken:
+ token = CruEventHandlerToken(self, handler, once)
+ self._tokens.append(token)
+ return token
+
+ def unregister(self, *handlers: CruEventHandlerToken | Callable[_P, _R]) -> int:
+ old_length = len(self._tokens)
+ self._tokens.reset(
+ self._tokens.as_cru_iterator().filter(
+ (lambda t: t in handlers or t.handler in handlers)
+ )
+ )
+ return old_length - len(self._tokens)
+
+ def trigger(self, *args: _P.args, **kwargs: _P.kwargs) -> CruList[_R]:
+ results = CruList(
+ self._tokens.as_cru_iterator()
+ .transform(lambda t: t.handler(*args, **kwargs))
+ .to_list()
+ )
+ self._tokens.reset(self._tokens.as_cru_iterator().filter(lambda t: not t.once))
+ return results
diff --git a/tools/cru-py/cru/_func.py b/tools/cru-py/cru/_func.py
new file mode 100644
index 0000000..fc57802
--- /dev/null
+++ b/tools/cru-py/cru/_func.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable
+from enum import Flag, auto
+from typing import (
+ Any,
+ Generic,
+ Literal,
+ ParamSpec,
+ TypeAlias,
+ TypeVar,
+)
+
+
+from ._base import CRU
+from ._const import CruPlaceholder
+
+_P = ParamSpec("_P")
+_P1 = ParamSpec("_P1")
+_T = TypeVar("_T")
+
+
+class _Dec:
+ @staticmethod
+ def wrap(
+ origin: Callable[_P, Callable[_P1, _T]]
+ ) -> Callable[_P, _Wrapper[_P1, _T]]:
+ def _wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _Wrapper[_P1, _T]:
+ return _Wrapper(origin(*args, **kwargs))
+
+ return _wrapped
+
+
+class _RawBase:
+ @staticmethod
+ def none(*_v, **_kwargs) -> None:
+ return None
+
+ @staticmethod
+ def true(*_v, **_kwargs) -> Literal[True]:
+ return True
+
+ @staticmethod
+ def false(*_v, **_kwargs) -> Literal[False]:
+ return False
+
+ @staticmethod
+ def identity(v: _T) -> _T:
+ return v
+
+ @staticmethod
+ def only_you(v: _T, *_v, **_kwargs) -> _T:
+ return v
+
+ @staticmethod
+ def equal(a: Any, b: Any) -> bool:
+ return a == b
+
+ @staticmethod
+ def not_equal(a: Any, b: Any) -> bool:
+ return a != b
+
+ @staticmethod
+ def not_(v: Any) -> Any:
+ return not v
+
+
+class _Wrapper(Generic[_P, _T]):
+ def __init__(self, f: Callable[_P, _T]):
+ self._f = f
+
+ @property
+ def me(self) -> Callable[_P, _T]:
+ return self._f
+
+ def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _T:
+ return self._f(*args, **kwargs)
+
+ @_Dec.wrap
+ def bind(self, *bind_args, **bind_kwargs) -> Callable[..., _T]:
+ func = self.me
+
+ def bound_func(*args, **kwargs):
+ popped = 0
+ real_args = []
+ for arg in bind_args:
+ if CruPlaceholder.check(arg):
+ real_args.append(args[popped])
+ popped += 1
+ else:
+ real_args.append(arg)
+ real_args.extend(args[popped:])
+ return func(*real_args, **(bind_kwargs | kwargs))
+
+ return bound_func
+
+ class ChainMode(Flag):
+ ARGS = auto()
+ KWARGS = auto()
+ BOTH = ARGS | KWARGS
+
+ ArgsChainableCallable: TypeAlias = Callable[..., Iterable[Any]]
+ KwargsChainableCallable: TypeAlias = Callable[..., Iterable[tuple[str, Any]]]
+ ChainableCallable: TypeAlias = Callable[
+ ..., tuple[Iterable[Any], Iterable[tuple[str, Any]]]
+ ]
+
+ @_Dec.wrap
+ def chain_with_args(
+ self, funcs: Iterable[ArgsChainableCallable], *bind_args, **bind_kwargs
+ ) -> ArgsChainableCallable:
+ def chained_func(*args):
+ args = self.bind(*bind_args, **bind_kwargs)(*args)
+
+ for func in funcs:
+ args = _Wrapper(func).bind(*bind_args, **bind_kwargs)(*args)
+ return args
+
+ return chained_func
+
+ @_Dec.wrap
+ def chain_with_kwargs(
+ self, funcs: Iterable[KwargsChainableCallable], *bind_args, **bind_kwargs
+ ) -> KwargsChainableCallable:
+ def chained_func(**kwargs):
+ kwargs = self.bind(*bind_args, **bind_kwargs)(**kwargs)
+ for func in funcs:
+ kwargs = _Wrapper(func).bind(func, *bind_args, **bind_kwargs)(**kwargs)
+ return kwargs
+
+ return chained_func
+
+ @_Dec.wrap
+ def chain_with_both(
+ self, funcs: Iterable[ChainableCallable], *bind_args, **bind_kwargs
+ ) -> ChainableCallable:
+ def chained_func(*args, **kwargs):
+ for func in funcs:
+ args, kwargs = _Wrapper(func).bind(func, *bind_args, **bind_kwargs)(
+ *args, **kwargs
+ )
+ return args, kwargs
+
+ return chained_func
+
+
+class _Base:
+ none = _Wrapper(_RawBase.none)
+ true = _Wrapper(_RawBase.true)
+ false = _Wrapper(_RawBase.false)
+ identity = _Wrapper(_RawBase.identity)
+ only_you = _Wrapper(_RawBase.only_you)
+ equal = _Wrapper(_RawBase.equal)
+ not_equal = _Wrapper(_RawBase.not_equal)
+ not_ = _Wrapper(_RawBase.not_)
+
+
+class _Creators:
+ @staticmethod
+ def make_isinstance_of_types(*types: type) -> Callable:
+ return _Wrapper(lambda v: type(v) in types)
+
+
+class CruFunction:
+ RawBase: TypeAlias = _RawBase
+ Base: TypeAlias = _Base
+ Creators: TypeAlias = _Creators
+ Wrapper: TypeAlias = _Wrapper
+ Decorators: TypeAlias = _Dec
+
+
+CRU.add_objects(CruFunction)
diff --git a/tools/cru-py/cru/_helper.py b/tools/cru-py/cru/_helper.py
new file mode 100644
index 0000000..43baf46
--- /dev/null
+++ b/tools/cru-py/cru/_helper.py
@@ -0,0 +1,16 @@
+from collections.abc import Callable
+from typing import Any, Iterable, TypeVar, cast
+
+_T = TypeVar("_T")
+_D = TypeVar("_D")
+
+
+def remove_element(
+ iterable: Iterable[_T | None], to_rm: Iterable[Any], des: type[_D] | None = None
+) -> _D:
+ to_rm = set(to_rm)
+ return cast(Callable[..., _D], des or list)(v for v in iterable if v not in to_rm)
+
+
+def remove_none(iterable: Iterable[_T | None], des: type[_D] | None = None) -> _D:
+ return cast(Callable[..., _D], des or list)(v for v in iterable if v is not None)
diff --git a/tools/cru-py/cru/_iter.py b/tools/cru-py/cru/_iter.py
new file mode 100644
index 0000000..8f58561
--- /dev/null
+++ b/tools/cru-py/cru/_iter.py
@@ -0,0 +1,466 @@
+from __future__ import annotations
+
+from collections.abc import Iterable, Callable, Generator, Iterator
+from dataclasses import dataclass
+from enum import Enum
+from typing import (
+ Concatenate,
+ Literal,
+ Never,
+ Self,
+ TypeAlias,
+ TypeVar,
+ ParamSpec,
+ Any,
+ Generic,
+ cast,
+)
+
+from ._base import CRU
+from ._const import CruNotFound
+from ._error import cru_unreachable
+
+_P = ParamSpec("_P")
+_T = TypeVar("_T")
+_O = TypeVar("_O")
+_V = TypeVar("_V")
+_R = TypeVar("_R")
+
+
+class _Generic:
+ class StepActionKind(Enum):
+ SKIP = 0
+ PUSH = 1
+ STOP = 2
+ AGGREGATE = 3
+
+ @dataclass
+ class StepAction(Generic[_V, _R]):
+ value: Iterable[Self] | _V | _R | None
+ kind: _Generic.StepActionKind
+
+ @property
+ def push_value(self) -> _V:
+ assert self.kind == _Generic.StepActionKind.PUSH
+ return cast(_V, self.value)
+
+ @property
+ def stop_value(self) -> _R:
+ assert self.kind == _Generic.StepActionKind.STOP
+ return cast(_R, self.value)
+
+ @staticmethod
+ def skip() -> _Generic.StepAction[_V, _R]:
+ return _Generic.StepAction(None, _Generic.StepActionKind.SKIP)
+
+ @staticmethod
+ def push(value: _V | None) -> _Generic.StepAction[_V, _R]:
+ return _Generic.StepAction(value, _Generic.StepActionKind.PUSH)
+
+ @staticmethod
+ def stop(value: _R | None = None) -> _Generic.StepAction[_V, _R]:
+ return _Generic.StepAction(value, _Generic.StepActionKind.STOP)
+
+ @staticmethod
+ def aggregate(
+ *results: _Generic.StepAction[_V, _R],
+ ) -> _Generic.StepAction[_V, _R]:
+ return _Generic.StepAction(results, _Generic.StepActionKind.AGGREGATE)
+
+ @staticmethod
+ def push_last(value: _V | None) -> _Generic.StepAction[_V, _R]:
+ return _Generic.StepAction.aggregate(
+ _Generic.StepAction.push(value), _Generic.StepAction.stop()
+ )
+
+ def flatten(self) -> Iterable[Self]:
+ return _Generic.flatten(
+ self,
+ is_leave=lambda r: r.kind != _Generic.StepActionKind.AGGREGATE,
+ get_children=lambda r: cast(Iterable[Self], r.value),
+ )
+
+ GeneralStepAction: TypeAlias = StepAction[_V, _R] | _V | _R | None
+ IterateOperation: TypeAlias = Callable[[_T, int], GeneralStepAction[_V, _R]]
+ IteratePreHook: TypeAlias = Callable[[Iterable[_T]], GeneralStepAction[_V, _R]]
+ IteratePostHook: TypeAlias = Callable[[int], GeneralStepAction[_V, _R]]
+
+ @staticmethod
+ def _is_not_iterable(o: Any) -> bool:
+ return not isinstance(o, Iterable)
+
+ @staticmethod
+ def _return_self(o):
+ return o
+
+ @staticmethod
+ def iterable_flatten(
+ maybe_iterable: Iterable[_T] | _T, max_depth: int = -1, *, _depth: int = 0
+ ) -> Iterable[Iterable[_T] | _T]:
+ if _depth == max_depth or not isinstance(maybe_iterable, Iterable):
+ yield maybe_iterable
+ return
+
+ for child in maybe_iterable:
+ yield from _Generic.iterable_flatten(
+ child,
+ max_depth,
+ _depth=_depth + 1,
+ )
+
+ @staticmethod
+ def flatten(
+ o: _O,
+ max_depth: int = -1,
+ /,
+ is_leave: CruIterator.ElementPredicate[_O] = _is_not_iterable,
+ get_children: CruIterator.ElementTransformer[_O, Iterable[_O]] = _return_self,
+ *,
+ _depth: int = 0,
+ ) -> Iterable[_O]:
+ if _depth == max_depth or is_leave(o):
+ yield o
+ return
+ for child in get_children(o):
+ yield from _Generic.flatten(
+ child,
+ max_depth,
+ is_leave,
+ get_children,
+ _depth=_depth + 1,
+ )
+
+ class Results:
+ @staticmethod
+ def true(_) -> Literal[True]:
+ return True
+
+ @staticmethod
+ def false(_) -> Literal[False]:
+ return False
+
+ @staticmethod
+ def not_found(_) -> Literal[CruNotFound.VALUE]:
+ return CruNotFound.VALUE
+
+ @staticmethod
+ def _non_result_to_push(value: Any) -> StepAction[_V, _R]:
+ return _Generic.StepAction.push(value)
+
+ @staticmethod
+ def _non_result_to_stop(value: Any) -> StepAction[_V, _R]:
+ return _Generic.StepAction.stop(value)
+
+ @staticmethod
+ def _none_hook(_: Any) -> StepAction[_V, _R]:
+ return _Generic.StepAction.skip()
+
+ def iterate(
+ iterable: Iterable[_T],
+ operation: IterateOperation[_T, _V, _R],
+ fallback_return: _R,
+ pre_iterate: IteratePreHook[_T, _V, _R],
+ post_iterate: IteratePostHook[_V, _R],
+ convert_value_result: Callable[[_V | _R | None], StepAction[_V, _R]],
+ ) -> Generator[_V, None, _R]:
+ pre_result = pre_iterate(iterable)
+ if not isinstance(pre_result, _Generic.StepAction):
+ real_pre_result = convert_value_result(pre_result)
+ for r in real_pre_result.flatten():
+ if r.kind == _Generic.StepActionKind.STOP:
+ return r.stop_value
+ elif r.kind == _Generic.StepActionKind.PUSH:
+ yield r.push_value
+ else:
+ assert r.kind == _Generic.StepActionKind.SKIP
+
+ for index, element in enumerate(iterable):
+ result = operation(element, index)
+ if not isinstance(result, _Generic.StepAction):
+ real_result = convert_value_result(result)
+ for r in real_result.flatten():
+ if r.kind == _Generic.StepActionKind.STOP:
+ return r.stop_value
+ elif r.kind == _Generic.StepActionKind.PUSH:
+ yield r.push_value
+ else:
+ assert r.kind == _Generic.StepActionKind.SKIP
+ continue
+
+ post_result = post_iterate(index + 1)
+ if not isinstance(post_result, _Generic.StepAction):
+ real_post_result = convert_value_result(post_result)
+ for r in real_post_result.flatten():
+ if r.kind == _Generic.StepActionKind.STOP:
+ return r.stop_value
+ elif r.kind == _Generic.StepActionKind.PUSH:
+ yield r.push_value
+ else:
+ assert r.kind == _Generic.StepActionKind.SKIP
+
+ return fallback_return
+
+ def create_new(
+ iterable: Iterable[_T],
+ operation: IterateOperation[_T, _V, _R],
+ fallback_return: _R,
+ /,
+ pre_iterate: IteratePreHook[_T, _V, _R] | None = None,
+ post_iterate: IteratePostHook[_V, _R] | None = None,
+ ) -> Generator[_V, None, _R]:
+ return _Generic.iterate(
+ iterable,
+ operation,
+ fallback_return,
+ pre_iterate or _Generic._none_hook,
+ post_iterate or _Generic._none_hook,
+ _Generic._non_result_to_push,
+ )
+
+ def get_result(
+ iterable: Iterable[_T],
+ operation: IterateOperation[_T, _V, _R],
+ fallback_return: _R,
+ /,
+ pre_iterate: IteratePreHook[_T, _V, _R] | None = None,
+ post_iterate: IteratePostHook[_V, _R] | None = None,
+ ) -> _R:
+ try:
+ for _ in _Generic.iterate(
+ iterable,
+ operation,
+ fallback_return,
+ pre_iterate or _Generic._none_hook,
+ post_iterate or _Generic._none_hook,
+ _Generic._non_result_to_stop,
+ ):
+ pass
+ except StopIteration as stop:
+ return stop.value
+ cru_unreachable()
+
+
+class _Helpers:
+ @staticmethod
+ def auto_count(c: Callable[Concatenate[int, _P], _O]) -> Callable[_P, _O]:
+ count = 0
+
+ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _O:
+ nonlocal count
+ r = c(count, *args, **kwargs)
+ count += 1
+ return r
+
+ return wrapper
+
+
+class _Creators:
+ class Raw:
+ @staticmethod
+ def empty() -> Iterator[Never]:
+ return iter([])
+
+ @staticmethod
+ def range(*args) -> Iterator[int]:
+ return iter(range(*args))
+
+ @staticmethod
+ def unite(*args: _T) -> Iterator[_T]:
+ return iter(args)
+
+ @staticmethod
+ def _concat(*iterables: Iterable[_T]) -> Iterable[_T]:
+ for iterable in iterables:
+ yield from iterable
+
+ @staticmethod
+ def concat(*iterables: Iterable[_T]) -> Iterator[_T]:
+ return iter(_Creators.Raw._concat(*iterables))
+
+ @staticmethod
+ def _wrap(f: Callable[_P, Iterable[_O]]) -> Callable[_P, CruIterator[_O]]:
+ def _wrapped(*args: _P.args, **kwargs: _P.kwargs) -> CruIterator[_O]:
+ return CruIterator(f(*args, **kwargs))
+
+ return _wrapped
+
+ empty = _wrap(Raw.empty)
+ range = _wrap(Raw.range)
+ unite = _wrap(Raw.unite)
+ concat = _wrap(Raw.concat)
+
+
+class CruIterator(Generic[_T]):
+ ElementOperation: TypeAlias = Callable[[_V], Any]
+ ElementPredicate: TypeAlias = Callable[[_V], bool]
+ AnyElementPredicate: TypeAlias = ElementPredicate[Any]
+ ElementTransformer: TypeAlias = Callable[[_V], _O]
+ SelfElementTransformer: TypeAlias = ElementTransformer[_V, _V]
+ AnyElementTransformer: TypeAlias = ElementTransformer[Any, Any]
+
+ Creators: TypeAlias = _Creators
+ Helpers: TypeAlias = _Helpers
+
+ def __init__(self, iterable: Iterable[_T]) -> None:
+ self._iterator = iter(iterable)
+
+ def __iter__(self) -> Iterator[_T]:
+ return self._iterator
+
+ def create_new_me(self, iterable: Iterable[_O]) -> CruIterator[_O]:
+ return type(self)(iterable) # type: ignore
+
+ @staticmethod
+ def _wrap(
+ f: Callable[Concatenate[CruIterator[_T], _P], Iterable[_O]],
+ ) -> Callable[Concatenate[CruIterator[_T], _P], CruIterator[_O]]:
+ def _wrapped(
+ self: CruIterator[_T], *args: _P.args, **kwargs: _P.kwargs
+ ) -> CruIterator[_O]:
+ return self.create_new_me(f(self, *args, **kwargs))
+
+ return _wrapped
+
+ @_wrap
+ def replace_me(self, iterable: Iterable[_O]) -> Iterable[_O]:
+ return iterable
+
+ def replace_me_with_empty(self) -> CruIterator[Never]:
+ return self.create_new_me(_Creators.Raw.empty())
+
+ def replace_me_with_range(self, *args) -> CruIterator[int]:
+ return self.create_new_me(_Creators.Raw.range(*args))
+
+ def replace_me_with_unite(self, *args: _O) -> CruIterator[_O]:
+ return self.create_new_me(_Creators.Raw.unite(*args))
+
+ def replace_me_with_concat(self, *iterables: Iterable[_T]) -> CruIterator[_T]:
+ return self.create_new_me(_Creators.Raw.concat(*iterables))
+
+ def to_set(self) -> set[_T]:
+ return set(self)
+
+ def to_list(self) -> list[_T]:
+ return list(self)
+
+ def all(self, predicate: ElementPredicate[_T]) -> bool:
+ for value in self:
+ if not predicate(value):
+ return False
+ return True
+
+ def any(self, predicate: ElementPredicate[_T]) -> bool:
+ for value in self:
+ if predicate(value):
+ return True
+ return False
+
+ def foreach(self, operation: ElementOperation[_T]) -> None:
+ for value in self:
+ operation(value)
+
+ @_wrap
+ def transform(self, transformer: ElementTransformer[_T, _O]) -> Iterable[_O]:
+ for value in self:
+ yield transformer(value)
+
+ map = transform
+
+ @_wrap
+ def filter(self, predicate: ElementPredicate[_T]) -> Iterable[_T]:
+ for value in self:
+ if predicate(value):
+ yield value
+
+ @_wrap
+ def continue_if(self, predicate: ElementPredicate[_T]) -> Iterable[_T]:
+ for value in self:
+ yield value
+ if not predicate(value):
+ break
+
+ def first_n(self, max_count: int) -> CruIterator[_T]:
+ if max_count < 0:
+ raise ValueError("max_count must be 0 or positive.")
+ if max_count == 0:
+ return self.replace_me_with_empty() # type: ignore
+ return self.continue_if(_Helpers.auto_count(lambda i, _: i < max_count - 1))
+
+ def drop_n(self, n: int) -> CruIterator[_T]:
+ if n < 0:
+ raise ValueError("n must be 0 or positive.")
+ if n == 0:
+ return self
+ return self.filter(_Helpers.auto_count(lambda i, _: i < n))
+
+ def single_or(
+ self, fallback: _O | CruNotFound = CruNotFound.VALUE
+ ) -> _T | _O | CruNotFound:
+ first_2 = self.first_n(2)
+ has_value = False
+ for element in first_2:
+ if has_value:
+ raise ValueError("More than one value found.")
+ has_value = True
+ value = element
+ if has_value:
+ return value
+ else:
+ return fallback
+
+ def first_or(
+ self, fallback: _O | CruNotFound = CruNotFound.VALUE
+ ) -> _T | _O | CruNotFound:
+ return self.first_n(1).single_or(fallback)
+
+ @_wrap
+ def flatten(self, max_depth: int = -1) -> Iterable[Any]:
+ return _Generic.iterable_flatten(self, max_depth)
+
+ def select_by_indices(self, indices: Iterable[int]) -> CruIterator[_T]:
+ index_set = set(indices)
+ max_index = max(index_set)
+ return self.first_n(max_index + 1).filter(
+ _Helpers.auto_count(lambda i, _: i in index_set)
+ )
+
+ def remove_values(self, values: Iterable[Any]) -> CruIterator[_T]:
+ value_set = set(values)
+ return self.filter(lambda v: v not in value_set)
+
+ def replace_values(
+ self, old_values: Iterable[Any], new_value: _O
+ ) -> Iterable[_T | _O]:
+ value_set = set(old_values)
+ return self.transform(lambda v: new_value if v in value_set else v)
+
+ def group_by(self, key_getter: Callable[[_T], _O]) -> dict[_O, list[_T]]:
+ result: dict[_O, list[_T]] = {}
+
+ for item in self:
+ key = key_getter(item)
+ if key not in result:
+ result[key] = []
+ result[key].append(item)
+
+ return result
+
+
+class CruIterMixin(Generic[_T]):
+ def cru_iter(self: Iterable[_T]) -> CruIterator[_T]:
+ return CruIterator(self)
+
+
+class CruIterList(list[_T], CruIterMixin[_T]):
+ pass
+
+
+class CruIterable:
+ Generic: TypeAlias = _Generic
+ Iterator: TypeAlias = CruIterator[_T]
+ Helpers: TypeAlias = _Helpers
+ Mixin: TypeAlias = CruIterMixin[_T]
+ IterList: TypeAlias = CruIterList[_T]
+
+
+CRU.add_objects(CruIterable, CruIterator)
diff --git a/tools/cru-py/cru/_type.py b/tools/cru-py/cru/_type.py
new file mode 100644
index 0000000..1f81da3
--- /dev/null
+++ b/tools/cru-py/cru/_type.py
@@ -0,0 +1,52 @@
+from collections.abc import Iterable
+from typing import Any
+
+from ._error import CruException, CruLogicError
+from ._iter import CruIterator
+
+
+class CruTypeCheckError(CruException):
+ pass
+
+
+DEFAULT_NONE_ERR_MSG = "None is not allowed here."
+DEFAULT_TYPE_ERR_MSG = "Object of this type is not allowed here."
+
+
+class CruTypeSet(set[type]):
+ def __init__(self, *types: type):
+ type_set = CruIterator(types).filter(lambda t: t is not None).to_set()
+ if not CruIterator(type_set).all(lambda t: isinstance(t, type)):
+ raise CruLogicError("TypeSet can only contain type.")
+ super().__init__(type_set)
+
+ def check_value(
+ self,
+ value: Any,
+ /,
+ allow_none: bool = False,
+ empty_allow_all: bool = True,
+ ) -> None:
+ if value is None:
+ if allow_none:
+ return
+ else:
+ raise CruTypeCheckError(DEFAULT_NONE_ERR_MSG)
+ if len(self) == 0 and empty_allow_all:
+ return
+ if not CruIterator(self).any(lambda t: isinstance(value, t)):
+ raise CruTypeCheckError(DEFAULT_TYPE_ERR_MSG)
+
+ def check_value_list(
+ self,
+ values: Iterable[Any],
+ /,
+ allow_none: bool = False,
+ empty_allow_all: bool = True,
+ ) -> None:
+ for value in values:
+ self.check_value(
+ value,
+ allow_none,
+ empty_allow_all,
+ )
diff --git a/tools/cru-py/cru/attr.py b/tools/cru-py/cru/attr.py
new file mode 100644
index 0000000..d4cc86a
--- /dev/null
+++ b/tools/cru-py/cru/attr.py
@@ -0,0 +1,364 @@
+from __future__ import annotations
+
+import copy
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass, field
+from typing import Any
+
+from .list import CruUniqueKeyList
+from ._type import CruTypeSet
+from ._const import CruNotFound, CruUseDefault, CruDontChange
+from ._iter import CruIterator
+
+
+@dataclass
+class CruAttr:
+
+ name: str
+ value: Any
+ description: str | None
+
+ @staticmethod
+ def make(
+ name: str, value: Any = CruUseDefault.VALUE, description: str | None = None
+ ) -> CruAttr:
+ return CruAttr(name, value, description)
+
+
+CruAttrDefaultFactory = Callable[["CruAttrDef"], Any]
+CruAttrTransformer = Callable[[Any, "CruAttrDef"], Any]
+CruAttrValidator = Callable[[Any, "CruAttrDef"], None]
+
+
+@dataclass
+class CruAttrDef:
+ name: str
+ description: str
+ default_factory: CruAttrDefaultFactory
+ transformer: CruAttrTransformer
+ validator: CruAttrValidator
+
+ def __init__(
+ self,
+ name: str,
+ description: str,
+ default_factory: CruAttrDefaultFactory,
+ transformer: CruAttrTransformer,
+ validator: CruAttrValidator,
+ ) -> None:
+ self.name = name
+ self.description = description
+ self.default_factory = default_factory
+ self.transformer = transformer
+ self.validator = validator
+
+ def transform(self, value: Any) -> Any:
+ if self.transformer is not None:
+ return self.transformer(value, self)
+ return value
+
+ def validate(self, value: Any, /, force_allow_none: bool = False) -> None:
+ if force_allow_none is value is None:
+ return
+ if self.validator is not None:
+ self.validator(value, self)
+
+ def transform_and_validate(
+ self, value: Any, /, force_allow_none: bool = False
+ ) -> Any:
+ value = self.transform(value)
+ self.validate(value, force_allow_none)
+ return value
+
+ def make_default_value(self) -> Any:
+ return self.transform_and_validate(self.default_factory(self))
+
+ def adopt(self, attr: CruAttr) -> CruAttr:
+ attr = copy.deepcopy(attr)
+
+ if attr.name is None:
+ attr.name = self.name
+ elif attr.name != self.name:
+ raise ValueError(f"Attr name is not match: {attr.name} != {self.name}")
+
+ if attr.value is CruUseDefault.VALUE:
+ attr.value = self.make_default_value()
+ else:
+ attr.value = self.transform_and_validate(attr.value)
+
+ if attr.description is None:
+ attr.description = self.description
+
+ return attr
+
+ def make(
+ self, value: Any = CruUseDefault.VALUE, description: None | str = None
+ ) -> CruAttr:
+ value = self.make_default_value() if value is CruUseDefault.VALUE else value
+ value = self.transform_and_validate(value)
+ return CruAttr(
+ self.name,
+ value,
+ description if description is not None else self.description,
+ )
+
+
+@dataclass
+class CruAttrDefBuilder:
+
+ name: str
+ description: str
+ types: list[type] | None = field(default=None)
+ allow_none: bool = field(default=False)
+ default: Any = field(default=CruUseDefault.VALUE)
+ default_factory: CruAttrDefaultFactory | None = field(default=None)
+ auto_list: bool = field(default=False)
+ transformers: list[CruAttrTransformer] = field(default_factory=list)
+ validators: list[CruAttrValidator] = field(default_factory=list)
+ override_transformer: CruAttrTransformer | None = field(default=None)
+ override_validator: CruAttrValidator | None = field(default=None)
+
+ build_hook: Callable[[CruAttrDef], None] | None = field(default=None)
+
+ def __init__(self, name: str, description: str) -> None:
+ super().__init__()
+ self.name = name
+ self.description = description
+
+ def auto_adjust_default(self) -> None:
+ if self.default is not CruUseDefault.VALUE and self.default is not None:
+ return
+ if self.allow_none and self.default is CruUseDefault.VALUE:
+ self.default = None
+ if not self.allow_none and self.default is None:
+ self.default = CruUseDefault.VALUE
+ if self.auto_list and not self.allow_none:
+ self.default = []
+
+ def with_name(self, name: str | CruDontChange) -> CruAttrDefBuilder:
+ if name is not CruDontChange.VALUE:
+ self.name = name
+ return self
+
+ def with_description(
+ self, default_description: str | CruDontChange
+ ) -> CruAttrDefBuilder:
+ if default_description is not CruDontChange.VALUE:
+ self.description = default_description
+ return self
+
+ def with_default(self, default: Any) -> CruAttrDefBuilder:
+ if default is not CruDontChange.VALUE:
+ self.default = default
+ return self
+
+ def with_default_factory(
+ self,
+ default_factory: CruAttrDefaultFactory | CruDontChange,
+ ) -> CruAttrDefBuilder:
+ if default_factory is not CruDontChange.VALUE:
+ self.default_factory = default_factory
+ return self
+
+ def with_types(
+ self,
+ types: Iterable[type] | None | CruDontChange,
+ ) -> CruAttrDefBuilder:
+ if types is not CruDontChange.VALUE:
+ self.types = None if types is None else list(types)
+ return self
+
+ def with_allow_none(self, allow_none: bool | CruDontChange) -> CruAttrDefBuilder:
+ if allow_none is not CruDontChange.VALUE:
+ self.allow_none = allow_none
+ return self
+
+ def with_auto_list(
+ self, auto_list: bool | CruDontChange = True
+ ) -> CruAttrDefBuilder:
+ if auto_list is not CruDontChange.VALUE:
+ self.auto_list = auto_list
+ return self
+
+ def with_constraint(
+ self,
+ /,
+ allow_none: bool | CruDontChange = CruDontChange.VALUE,
+ types: Iterable[type] | None | CruDontChange = CruDontChange.VALUE,
+ default: Any = CruDontChange.VALUE,
+ default_factory: CruAttrDefaultFactory | CruDontChange = CruDontChange.VALUE,
+ auto_list: bool | CruDontChange = CruDontChange.VALUE,
+ ) -> CruAttrDefBuilder:
+ return (
+ self.with_allow_none(allow_none)
+ .with_types(types)
+ .with_default(default)
+ .with_default_factory(default_factory)
+ .with_auto_list(auto_list)
+ )
+
+ def add_transformer(self, transformer: CruAttrTransformer) -> CruAttrDefBuilder:
+ self.transformers.append(transformer)
+ return self
+
+ def clear_transformers(self) -> CruAttrDefBuilder:
+ self.transformers.clear()
+ return self
+
+ def add_validator(self, validator: CruAttrValidator) -> CruAttrDefBuilder:
+ self.validators.append(validator)
+ return self
+
+ def clear_validators(self) -> CruAttrDefBuilder:
+ self.validators.clear()
+ return self
+
+ def with_override_transformer(
+ self, override_transformer: CruAttrTransformer | None | CruDontChange
+ ) -> CruAttrDefBuilder:
+ if override_transformer is not CruDontChange.VALUE:
+ self.override_transformer = override_transformer
+ return self
+
+ def with_override_validator(
+ self, override_validator: CruAttrValidator | None | CruDontChange
+ ) -> CruAttrDefBuilder:
+ if override_validator is not CruDontChange.VALUE:
+ self.override_validator = override_validator
+ return self
+
+ def is_valid(self) -> tuple[bool, str]:
+ if not isinstance(self.name, str):
+ return False, "Name must be a string!"
+ if not isinstance(self.description, str):
+ return False, "Default description must be a string!"
+ if (
+ not self.allow_none
+ and self.default is None
+ and self.default_factory is None
+ ):
+ return False, "Default must be set if allow_none is False!"
+ return True, ""
+
+ @staticmethod
+ def _build(
+ builder: CruAttrDefBuilder, auto_adjust_default: bool = True
+ ) -> CruAttrDef:
+ if auto_adjust_default:
+ builder.auto_adjust_default()
+
+ valid, err = builder.is_valid()
+ if not valid:
+ raise ValueError(err)
+
+ def composed_transformer(value: Any, attr_def: CruAttrDef) -> Any:
+ def transform_value(single_value: Any) -> Any:
+ for transformer in builder.transformers:
+ single_value = transformer(single_value, attr_def)
+ return single_value
+
+ if builder.auto_list:
+ if not isinstance(value, list):
+ value = [value]
+ value = CruIterator(value).transform(transform_value).to_list()
+
+ else:
+ value = transform_value(value)
+ return value
+
+ type_set = None if builder.types is None else CruTypeSet(*builder.types)
+
+ def composed_validator(value: Any, attr_def: CruAttrDef):
+ def validate_value(single_value: Any) -> None:
+ if type_set is not None:
+ type_set.check_value(single_value, allow_none=builder.allow_none)
+ for validator in builder.validators:
+ validator(single_value, attr_def)
+
+ if builder.auto_list:
+ CruIterator(value).foreach(validate_value)
+ else:
+ validate_value(value)
+
+ real_transformer = builder.override_transformer or composed_transformer
+ real_validator = builder.override_validator or composed_validator
+
+ default_factory = builder.default_factory
+ if default_factory is None:
+
+ def default_factory(_d):
+ return copy.deepcopy(builder.default)
+
+ d = CruAttrDef(
+ builder.name,
+ builder.description,
+ default_factory,
+ real_transformer,
+ real_validator,
+ )
+ if builder.build_hook:
+ builder.build_hook(d)
+ return d
+
+ def build(self, auto_adjust_default=True) -> CruAttrDef:
+ c = copy.deepcopy(self)
+ self.build_hook = None
+ return CruAttrDefBuilder._build(c, auto_adjust_default)
+
+
+class CruAttrDefRegistry(CruUniqueKeyList[CruAttrDef, str]):
+
+ def __init__(self) -> None:
+ super().__init__(lambda d: d.name)
+
+ def make_builder(self, name: str, default_description: str) -> CruAttrDefBuilder:
+ b = CruAttrDefBuilder(name, default_description)
+ b.build_hook = lambda a: self.add(a)
+ return b
+
+ def adopt(self, attr: CruAttr) -> CruAttr:
+ d = self.get(attr.name)
+ return d.adopt(attr)
+
+
+class CruAttrTable(CruUniqueKeyList[CruAttr, str]):
+ def __init__(self, registry: CruAttrDefRegistry) -> None:
+ self._registry: CruAttrDefRegistry = registry
+ super().__init__(lambda a: a.name, before_add=registry.adopt)
+
+ @property
+ def registry(self) -> CruAttrDefRegistry:
+ return self._registry
+
+ def get_value_or(self, name: str, fallback: Any = CruNotFound.VALUE) -> Any:
+ a = self.get_or(name, CruNotFound.VALUE)
+ if a is CruNotFound.VALUE:
+ return fallback
+ return a.value
+
+ def get_value(self, name: str) -> Any:
+ a = self.get(name)
+ return a.value
+
+ def make_attr(
+ self,
+ name: str,
+ value: Any = CruUseDefault.VALUE,
+ /,
+ description: str | None = None,
+ ) -> CruAttr:
+ d = self._registry.get(name)
+ return d.make(value, description or d.description)
+
+ def add_value(
+ self,
+ name: str,
+ value: Any = CruUseDefault.VALUE,
+ /,
+ description: str | None = None,
+ *,
+ replace: bool = False,
+ ) -> CruAttr:
+ attr = self.make_attr(name, value, description)
+ self.add(attr, replace)
+ return attr
diff --git a/tools/cru-py/cru/config.py b/tools/cru-py/cru/config.py
new file mode 100644
index 0000000..0f6f0d0
--- /dev/null
+++ b/tools/cru-py/cru/config.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+from typing import Any, TypeVar, Generic
+
+from ._error import CruException
+from .list import CruUniqueKeyList
+from .value import (
+ INTEGER_VALUE_TYPE,
+ TEXT_VALUE_TYPE,
+ CruValueTypeError,
+ ValueGeneratorBase,
+ ValueType,
+)
+
+_T = TypeVar("_T")
+
+
+class CruConfigError(CruException):
+ def __init__(self, message: str, item: ConfigItem, *args, **kwargs):
+ super().__init__(message, *args, **kwargs)
+ self._item = item
+
+ @property
+ def item(self) -> ConfigItem:
+ return self._item
+
+
+class ConfigItem(Generic[_T]):
+ def __init__(
+ self,
+ name: str,
+ description: str,
+ value_type: ValueType[_T],
+ value: _T | None = None,
+ /,
+ default: ValueGeneratorBase[_T] | _T | None = None,
+ ) -> None:
+ self._name = name
+ self._description = description
+ self._value_type = value_type
+ self._value = value
+ self._default = default
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def description(self) -> str:
+ return self._description
+
+ @property
+ def value_type(self) -> ValueType[_T]:
+ return self._value_type
+
+ @property
+ def is_set(self) -> bool:
+ return self._value is not None
+
+ @property
+ def value(self) -> _T:
+ if self._value is None:
+ raise CruConfigError(
+ "Config value is not set.",
+ self,
+ user_message=f"Config item {self.name} is not set.",
+ )
+ return self._value
+
+ @property
+ def value_str(self) -> str:
+ return self.value_type.convert_value_to_str(self.value)
+
+ def set_value(self, v: _T | str, allow_convert_from_str=False):
+ if allow_convert_from_str:
+ self._value = self.value_type.check_value_or_try_convert_from_str(v)
+ else:
+ self._value = self.value_type.check_value(v)
+
+ def reset(self):
+ self._value = None
+
+ @property
+ def default(self) -> ValueGeneratorBase[_T] | _T | None:
+ return self._default
+
+ @property
+ def can_generate_default(self) -> bool:
+ return self.default is not None
+
+ def generate_default_value(self) -> _T:
+ if self.default is None:
+ raise CruConfigError(
+ "Config item does not support default value generation.", self
+ )
+ elif isinstance(self.default, ValueGeneratorBase):
+ v = self.default.generate()
+ else:
+ v = self.default
+ try:
+ self.value_type.check_value(v)
+ return v
+ except CruValueTypeError as e:
+ raise CruConfigError(
+ "Config value generator returns an invalid value.", self
+ ) from e
+
+ def copy(self) -> "ConfigItem":
+ return ConfigItem(
+ self.name,
+ self.description,
+ self.value_type,
+ self.value,
+ self.default,
+ )
+
+ @property
+ def description_str(self) -> str:
+ return f"{self.name} ({self.value_type.name}): {self.description}"
+
+
+class Configuration(CruUniqueKeyList[ConfigItem[Any], str]):
+ def __init__(self):
+ super().__init__(lambda c: c.name)
+
+ def get_set_items(self) -> list[ConfigItem[Any]]:
+ return [item for item in self if item.is_set]
+
+ def get_unset_items(self) -> list[ConfigItem[Any]]:
+ return [item for item in self if not item.is_set]
+
+ @property
+ def all_set(self) -> bool:
+ return len(self.get_unset_items()) == 0
+
+ @property
+ def all_not_set(self) -> bool:
+ return len(self.get_set_items()) == 0
+
+ def add_text_config(
+ self,
+ name: str,
+ description: str,
+ value: str | None = None,
+ default: ValueGeneratorBase[str] | str | None = None,
+ ) -> ConfigItem[str]:
+ item = ConfigItem(name, description, TEXT_VALUE_TYPE, value, default)
+ self.add(item)
+ return item
+
+ def add_int_config(
+ self,
+ name: str,
+ description: str,
+ value: int | None = None,
+ default: ValueGeneratorBase[int] | int | None = None,
+ ) -> ConfigItem[int]:
+ item = ConfigItem(name, description, INTEGER_VALUE_TYPE, value, default)
+ self.add(item)
+ return item
+
+ def set_config_item(
+ self,
+ name: str,
+ value: Any | str,
+ allow_convert_from_str=True,
+ ) -> None:
+ item = self.get(name)
+ item.set_value(
+ value,
+ allow_convert_from_str=allow_convert_from_str,
+ )
+
+ def reset_all(self) -> None:
+ for item in self:
+ item.reset()
+
+ def to_dict(self) -> dict[str, Any]:
+ return {item.name: item.value for item in self}
+
+ def to_str_dict(self) -> dict[str, str]:
+ return {
+ item.name: item.value_type.convert_value_to_str(item.value) for item in self
+ }
+
+ def set_value_dict(
+ self,
+ value_dict: dict[str, Any],
+ allow_convert_from_str: bool = False,
+ ) -> None:
+ for name, value in value_dict.items():
+ item = self.get(name)
+ item.set_value(
+ value,
+ allow_convert_from_str=allow_convert_from_str,
+ )
diff --git a/tools/cru-py/cru/list.py b/tools/cru-py/cru/list.py
new file mode 100644
index 0000000..9d210b7
--- /dev/null
+++ b/tools/cru-py/cru/list.py
@@ -0,0 +1,160 @@
+from __future__ import annotations
+
+from collections.abc import Callable, Iterator
+from typing import Any, Generic, Iterable, TypeAlias, TypeVar, overload
+
+from ._error import CruInternalError
+from ._iter import CruIterator
+from ._const import CruNotFound
+
+_T = TypeVar("_T")
+_O = TypeVar("_O")
+
+
+class CruListEdit(CruIterator[_T]):
+ def __init__(self, iterable: Iterable[_T], _list: CruList[Any]) -> None:
+ super().__init__(iterable)
+ self._list = _list
+
+ def create_me(self, iterable: Iterable[_O]) -> CruListEdit[_O]:
+ return CruListEdit(iterable, self._list)
+
+ @property
+ def list(self) -> CruList[Any]:
+ return self._list
+
+ def done(self) -> CruList[Any]:
+ self._list.reset(self)
+ return self._list
+
+
+class CruList(list[_T]):
+ def reset(self, new_values: Iterable[_T]):
+ if self is new_values:
+ new_values = list(new_values)
+ self.clear()
+ self.extend(new_values)
+ return self
+
+ def as_cru_iterator(self) -> CruIterator[_T]:
+ return CruIterator(self)
+
+ @staticmethod
+ def make(maybe_list: Iterable[_T] | _T | None) -> CruList[_T]:
+ if maybe_list is None:
+ return CruList()
+ if isinstance(maybe_list, Iterable):
+ return CruList(maybe_list)
+ return CruList([maybe_list])
+
+
+_K = TypeVar("_K")
+
+_KeyGetter: TypeAlias = Callable[[_T], _K]
+
+
+class CruUniqueKeyList(Generic[_T, _K]):
+ def __init__(
+ self,
+ key_getter: _KeyGetter[_T, _K],
+ *,
+ before_add: Callable[[_T], _T] | None = None,
+ ):
+ super().__init__()
+ self._key_getter = key_getter
+ self._before_add = before_add
+ self._list: CruList[_T] = CruList()
+
+ @property
+ def key_getter(self) -> _KeyGetter[_T, _K]:
+ return self._key_getter
+
+ @property
+ def internal_list(self) -> CruList[_T]:
+ return self._list
+
+ def validate_self(self):
+ keys = self._list.transform(self._key_getter)
+ if len(keys) != len(set(keys)):
+ raise CruInternalError("Duplicate keys!")
+
+ @overload
+ def get_or(
+ self, key: _K, fallback: CruNotFound = CruNotFound.VALUE
+ ) -> _T | CruNotFound: ...
+
+ @overload
+ def get_or(self, key: _K, fallback: _O) -> _T | _O: ...
+
+ def get_or(
+ self, key: _K, fallback: _O | CruNotFound = CruNotFound.VALUE
+ ) -> _T | _O | CruNotFound:
+ return (
+ self._list.as_cru_iterator()
+ .filter(lambda v: key == self._key_getter(v))
+ .first_or(fallback)
+ )
+
+ def get(self, key: _K) -> _T:
+ value = self.get_or(key)
+ if value is CruNotFound:
+ raise KeyError(f"Key {key} not found!")
+ return value # type: ignore
+
+ @property
+ def keys(self) -> Iterable[_K]:
+ return self._list.as_cru_iterator().map(self._key_getter)
+
+ def has_key(self, key: _K) -> bool:
+ return self.get_or(key) != CruNotFound.VALUE
+
+ def try_remove(self, key: _K) -> bool:
+ value = self.get_or(key)
+ if value is CruNotFound.VALUE:
+ return False
+ self._list.remove(value)
+ return True
+
+ def remove(self, key: _K, allow_absence: bool = False) -> None:
+ if not self.try_remove(key) and not allow_absence:
+ raise KeyError(f"Key {key} not found!")
+
+ def add(self, value: _T, /, replace: bool = False) -> None:
+ v = self.get_or(self._key_getter(value))
+ if v is not CruNotFound.VALUE:
+ if not replace:
+ raise KeyError(f"Key {self._key_getter(v)} already exists!")
+ self._list.remove(v)
+ if self._before_add is not None:
+ value = self._before_add(value)
+ self._list.append(value)
+
+ def set(self, value: _T) -> None:
+ self.add(value, True)
+
+ def extend(self, iterable: Iterable[_T], /, replace: bool = False) -> None:
+ values = list(iterable)
+ to_remove = []
+ for value in values:
+ v = self.get_or(self._key_getter(value))
+ if v is not CruNotFound.VALUE:
+ if not replace:
+ raise KeyError(f"Key {self._key_getter(v)} already exists!")
+ to_remove.append(v)
+ for value in to_remove:
+ self._list.remove(value)
+ if self._before_add is not None:
+ values = [self._before_add(value) for value in values]
+ self._list.extend(values)
+
+ def clear(self) -> None:
+ self._list.reset([])
+
+ def __iter__(self) -> Iterator[_T]:
+ return iter(self._list)
+
+ def __len__(self) -> int:
+ return len(self._list)
+
+ def cru_iter(self) -> CruIterator[_T]:
+ return CruIterator(self._list)
diff --git a/tools/cru-py/cru/parsing.py b/tools/cru-py/cru/parsing.py
new file mode 100644
index 0000000..1d2fa7f
--- /dev/null
+++ b/tools/cru-py/cru/parsing.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import NamedTuple, TypeAlias, TypeVar, Generic, NoReturn, Callable
+
+from ._error import CruException
+from ._iter import CruIterable
+
+_T = TypeVar("_T")
+
+
+class ParseError(CruException, Generic[_T]):
+ def __init__(
+ self,
+ message,
+ parser: Parser[_T],
+ text: str,
+ line_number: int | None = None,
+ *args,
+ **kwargs,
+ ):
+ super().__init__(message, *args, **kwargs)
+ self._parser = parser
+ self._text = text
+ self._line_number = line_number
+
+ @property
+ def parser(self) -> Parser[_T]:
+ return self._parser
+
+ @property
+ def text(self) -> str:
+ return self._text
+
+ @property
+ def line_number(self) -> int | None:
+ return self._line_number
+
+
+class Parser(Generic[_T], metaclass=ABCMeta):
+ def __init__(self, name: str) -> None:
+ self._name = name
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @abstractmethod
+ def parse(self, s: str) -> _T:
+ raise NotImplementedError()
+
+ def raise_parse_exception(
+ self, text: str, line_number: int | None = None
+ ) -> NoReturn:
+ a = line_number and f" at line {line_number}" or ""
+ raise ParseError(f"Parser {self.name} failed{a}.", self, text, line_number)
+
+
+class SimpleLineConfigParserEntry(NamedTuple):
+ key: str
+ value: str
+ line_number: int | None = None
+
+
+class SimpleLineConfigParserResult(CruIterable.IterList[SimpleLineConfigParserEntry]):
+ pass
+
+
+class SimpleLineConfigParser(Parser[SimpleLineConfigParserResult]):
+ """
+ The parsing result is a list of tuples (key, value, line number).
+ """
+
+ Entry: TypeAlias = SimpleLineConfigParserEntry
+ Result: TypeAlias = SimpleLineConfigParserResult
+
+ def __init__(self) -> None:
+ super().__init__(type(self).__name__)
+
+ def _parse(self, text: str, callback: Callable[[Entry], None]) -> None:
+ for ln, line in enumerate(text.splitlines()):
+ line_number = ln + 1
+ # check if it's a comment
+ if line.strip().startswith("#"):
+ continue
+ # check if there is a '='
+ if line.find("=") == -1:
+ self.raise_parse_exception("There is even no '='!", line_number)
+ # split at first '='
+ key, value = line.split("=", 1)
+ key = key.strip()
+ value = value.strip()
+ callback(SimpleLineConfigParserEntry(key, value, line_number))
+
+ def parse(self, text: str) -> Result:
+ result = SimpleLineConfigParserResult()
+ self._parse(text, lambda item: result.append(item))
+ return result
diff --git a/tools/cru-py/cru/service/__init__.py b/tools/cru-py/cru/service/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/cru-py/cru/service/__init__.py
diff --git a/tools/cru-py/cru/service/__main__.py b/tools/cru-py/cru/service/__main__.py
new file mode 100644
index 0000000..1c10e82
--- /dev/null
+++ b/tools/cru-py/cru/service/__main__.py
@@ -0,0 +1,20 @@
+from cru import CruException
+
+from ._app import create_app
+
+
+def main():
+ app = create_app()
+ app.run_command()
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except CruException as e:
+ user_message = e.get_user_message()
+ if user_message is not None:
+ print(f"Error: {user_message}")
+ exit(1)
+ else:
+ raise
diff --git a/tools/cru-py/cru/service/_app.py b/tools/cru-py/cru/service/_app.py
new file mode 100644
index 0000000..6030dad
--- /dev/null
+++ b/tools/cru-py/cru/service/_app.py
@@ -0,0 +1,34 @@
+from ._base import (
+ AppBase,
+ CommandDispatcher,
+ AppInitializer,
+ PathCommandProvider,
+)
+from ._config import ConfigManager
+from ._template import TemplateManager
+from ._nginx import NginxManager
+from ._external import CliToolCommandProvider
+
+APP_ID = "crupest"
+
+
+class App(AppBase):
+ def __init__(self):
+ super().__init__(APP_ID, f"{APP_ID}-service")
+ self.add_feature(PathCommandProvider())
+ self.add_feature(AppInitializer())
+ self.add_feature(ConfigManager())
+ self.add_feature(TemplateManager())
+ self.add_feature(NginxManager())
+ self.add_feature(CliToolCommandProvider())
+ self.add_feature(CommandDispatcher())
+
+ def run_command(self):
+ command_dispatcher = self.get_feature(CommandDispatcher)
+ command_dispatcher.run_command()
+
+
+def create_app() -> App:
+ app = App()
+ app.setup()
+ return app
diff --git a/tools/cru-py/cru/service/_base.py b/tools/cru-py/cru/service/_base.py
new file mode 100644
index 0000000..ad813c9
--- /dev/null
+++ b/tools/cru-py/cru/service/_base.py
@@ -0,0 +1,449 @@
+from __future__ import annotations
+
+from argparse import ArgumentParser, Namespace
+from abc import ABC, abstractmethod
+import argparse
+import os
+from pathlib import Path
+from typing import TypeVar, overload
+
+from cru import CruException, CruLogicError
+
+_Feature = TypeVar("_Feature", bound="AppFeatureProvider")
+
+
+class AppError(CruException):
+ pass
+
+
+class AppFeatureError(AppError):
+ def __init__(self, message, feature: type | str, *args, **kwargs):
+ super().__init__(message, *args, **kwargs)
+ self._feature = feature
+
+ @property
+ def feature(self) -> type | str:
+ return self._feature
+
+
+class AppPathError(CruException):
+ def __init__(self, message, _path: str | Path, *args, **kwargs):
+ super().__init__(message, *args, **kwargs)
+ self._path = str(_path)
+
+ @property
+ def path(self) -> str:
+ return self._path
+
+
+class AppPath(ABC):
+ def __init__(self, id: str, is_dir: bool, description: str) -> None:
+ self._is_dir = is_dir
+ self._id = id
+ self._description = description
+
+ @property
+ @abstractmethod
+ def parent(self) -> AppPath | None: ...
+
+ @property
+ @abstractmethod
+ def app(self) -> AppBase: ...
+
+ @property
+ def id(self) -> str:
+ return self._id
+
+ @property
+ def description(self) -> str:
+ return self._description
+
+ @property
+ def is_dir(self) -> bool:
+ return self._is_dir
+
+ @property
+ @abstractmethod
+ def full_path(self) -> Path: ...
+
+ @property
+ def full_path_str(self) -> str:
+ return str(self.full_path)
+
+ def check_parents(self, must_exist: bool = False) -> bool:
+ for p in reversed(self.full_path.parents):
+ if not p.exists() and not must_exist:
+ return False
+ if not p.is_dir():
+ raise AppPathError("Parents' path must be a dir.", self.full_path)
+ return True
+
+ def check_self(self, must_exist: bool = False) -> bool:
+ if not self.check_parents(must_exist):
+ return False
+ if not self.full_path.exists():
+ if not must_exist:
+ return False
+ raise AppPathError("Not exist.", self.full_path)
+ if self.is_dir:
+ if not self.full_path.is_dir():
+ raise AppPathError("Should be a directory, but not.", self.full_path)
+ else:
+ return True
+ else:
+ if not self.full_path.is_file():
+ raise AppPathError("Should be a file, but not.", self.full_path)
+ else:
+ return True
+
+ def ensure(self, create_file: bool = False) -> None:
+ e = self.check_self(False)
+ if not e:
+ os.makedirs(self.full_path.parent, exist_ok=True)
+ if self.is_dir:
+ os.mkdir(self.full_path)
+ elif create_file:
+ with open(self.full_path, "w") as f:
+ f.write("")
+
+ def add_subpath(
+ self,
+ name: str,
+ is_dir: bool,
+ /,
+ id: str | None = None,
+ description: str = "",
+ ) -> AppFeaturePath:
+ return self.app.add_path(name, is_dir, self, id, description)
+
+ @property
+ def app_relative_path(self) -> Path:
+ return self.full_path.relative_to(self.app.root.full_path)
+
+
+class AppFeaturePath(AppPath):
+ def __init__(
+ self,
+ parent: AppPath,
+ name: str,
+ is_dir: bool,
+ /,
+ id: str | None = None,
+ description: str = "",
+ ) -> None:
+ super().__init__(id or name, is_dir, description)
+ self._name = name
+ self._parent = parent
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def parent(self) -> AppPath:
+ return self._parent
+
+ @property
+ def app(self) -> AppBase:
+ return self.parent.app
+
+ @property
+ def full_path(self) -> Path:
+ return Path(self.parent.full_path, self.name).resolve()
+
+
+class AppRootPath(AppPath):
+ def __init__(self, app: AppBase):
+ super().__init__("root", True, "Application root path.")
+ self._app = app
+ self._full_path: Path | None = None
+
+ @property
+ def parent(self) -> None:
+ return None
+
+ @property
+ def app(self) -> AppBase:
+ return self._app
+
+ @property
+ def full_path(self) -> Path:
+ if self._full_path is None:
+ raise AppError("App root path is not set yet.")
+ return self._full_path
+
+ def setup(self, path: os.PathLike) -> None:
+ if self._full_path is not None:
+ raise AppError("App root path is already set.")
+ self._full_path = Path(path).resolve()
+
+
+class AppFeatureProvider(ABC):
+ def __init__(self, name: str, /, app: AppBase | None = None):
+ super().__init__()
+ self._name = name
+ self._app = app if app else AppBase.get_instance()
+
+ @property
+ def app(self) -> AppBase:
+ return self._app
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @abstractmethod
+ def setup(self) -> None: ...
+
+
+class AppCommandFeatureProvider(AppFeatureProvider):
+ @abstractmethod
+ def get_command_info(self) -> tuple[str, str]: ...
+
+ @abstractmethod
+ def setup_arg_parser(self, arg_parser: ArgumentParser): ...
+
+ @abstractmethod
+ def run_command(self, args: Namespace) -> None: ...
+
+
+DATA_DIR_NAME = "data"
+
+
+class PathCommandProvider(AppCommandFeatureProvider):
+ def __init__(self) -> None:
+ super().__init__("path-command-provider")
+
+ def setup(self):
+ pass
+
+ def get_command_info(self):
+ return ("path", "Get information about paths used by app.")
+
+ def setup_arg_parser(self, arg_parser: ArgumentParser) -> None:
+ subparsers = arg_parser.add_subparsers(
+ dest="path_command", required=True, metavar="PATH_COMMAND"
+ )
+ _list_parser = subparsers.add_parser(
+ "list", help="list special paths used by app"
+ )
+
+ def run_command(self, args: Namespace) -> None:
+ if args.path_command == "list":
+ for path in self.app.paths:
+ print(f"{path.app_relative_path.as_posix()}: {path.description}")
+
+
+class CommandDispatcher(AppFeatureProvider):
+ def __init__(self) -> None:
+ super().__init__("command-dispatcher")
+ self._parsed_args: argparse.Namespace | None = None
+
+ def setup_arg_parser(self) -> None:
+ epilog = """
+==> to start,
+./tools/manage init
+./tools/manage config init
+ln -s generated/docker-compose.yaml .
+# Then edit config file.
+
+==> to update
+git pull
+./tools/manage template generate --no-dry-run
+docker compose up
+ """.strip()
+
+ self._map: dict[str, AppCommandFeatureProvider] = {}
+ arg_parser = argparse.ArgumentParser(
+ description="Service management",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=epilog,
+ )
+ arg_parser.add_argument(
+ "--project-dir",
+ help="The path of the project directory.",
+ required=True,
+ type=str,
+ )
+ subparsers = arg_parser.add_subparsers(
+ dest="command",
+ help="The management command to execute.",
+ metavar="COMMAND",
+ )
+ for feature in self.app.features:
+ if isinstance(feature, AppCommandFeatureProvider):
+ info = feature.get_command_info()
+ command_subparser = subparsers.add_parser(info[0], help=info[1])
+ feature.setup_arg_parser(command_subparser)
+ self._map[info[0]] = feature
+ self._arg_parser = arg_parser
+
+ def setup(self):
+ pass
+
+ @property
+ def arg_parser(self) -> argparse.ArgumentParser:
+ return self._arg_parser
+
+ @property
+ def map(self) -> dict[str, AppCommandFeatureProvider]:
+ return self._map
+
+ def get_program_parsed_args(self) -> argparse.Namespace:
+ if self._parsed_args is None:
+ self._parsed_args = self.arg_parser.parse_args()
+ return self._parsed_args
+
+ def run_command(self, args: argparse.Namespace | None = None) -> None:
+ real_args = args or self.get_program_parsed_args()
+ if real_args.command is None:
+ self.arg_parser.print_help()
+ return
+ self.map[real_args.command].run_command(real_args)
+
+
+class AppInitializer(AppCommandFeatureProvider):
+ def __init__(self) -> None:
+ super().__init__("app-initializer")
+
+ def _init_app(self) -> bool:
+ if self.app.app_initialized:
+ return False
+ self.app.data_dir.ensure()
+ return True
+
+ def setup(self):
+ pass
+
+ def get_command_info(self):
+ return ("init", "Initialize the app.")
+
+ def setup_arg_parser(self, arg_parser):
+ pass
+
+ def run_command(self, args):
+ init = self._init_app()
+ if init:
+ print("App initialized successfully.")
+ else:
+ print("App is already initialized. Do nothing.")
+
+
+class AppBase:
+ _instance: AppBase | None = None
+
+ @staticmethod
+ def get_instance() -> AppBase:
+ if AppBase._instance is None:
+ raise AppError("App instance not initialized")
+ return AppBase._instance
+
+ def __init__(self, app_id: str, name: str):
+ AppBase._instance = self
+ self._app_id = app_id
+ self._name = name
+ self._root = AppRootPath(self)
+ self._paths: list[AppFeaturePath] = []
+ self._features: list[AppFeatureProvider] = []
+
+ def setup(self) -> None:
+ command_dispatcher = self.get_feature(CommandDispatcher)
+ command_dispatcher.setup_arg_parser()
+ program_args = command_dispatcher.get_program_parsed_args()
+ self.setup_root(program_args.project_dir)
+ self._data_dir = self.add_path(DATA_DIR_NAME, True, id="data")
+ for feature in self.features:
+ feature.setup()
+ for path in self.paths:
+ path.check_self()
+
+ @property
+ def app_id(self) -> str:
+ return self._app_id
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def root(self) -> AppRootPath:
+ return self._root
+
+ def setup_root(self, path: os.PathLike) -> None:
+ self._root.setup(path)
+
+ @property
+ def data_dir(self) -> AppFeaturePath:
+ return self._data_dir
+
+ @property
+ def app_initialized(self) -> bool:
+ return self.data_dir.check_self()
+
+ def ensure_app_initialized(self) -> AppRootPath:
+ if not self.app_initialized:
+ raise AppError(
+ user_message="Root directory does not exist. "
+ "Please run 'init' to create one."
+ )
+ return self.root
+
+ @property
+ def features(self) -> list[AppFeatureProvider]:
+ return self._features
+
+ @property
+ def paths(self) -> list[AppFeaturePath]:
+ return self._paths
+
+ def add_feature(self, feature: _Feature) -> _Feature:
+ for f in self.features:
+ if f.name == feature.name:
+ raise AppFeatureError(
+ f"Duplicate feature name: {feature.name}.", feature.name
+ )
+ self._features.append(feature)
+ return feature
+
+ def add_path(
+ self,
+ name: str,
+ is_dir: bool,
+ /,
+ parent: AppPath | None = None,
+ id: str | None = None,
+ description: str = "",
+ ) -> AppFeaturePath:
+ p = AppFeaturePath(
+ parent or self.root, name, is_dir, id=id, description=description
+ )
+ self._paths.append(p)
+ return p
+
+ @overload
+ def get_feature(self, feature: str) -> AppFeatureProvider: ...
+
+ @overload
+ def get_feature(self, feature: type[_Feature]) -> _Feature: ...
+
+ def get_feature(
+ self, feature: str | type[_Feature]
+ ) -> AppFeatureProvider | _Feature:
+ if isinstance(feature, str):
+ for f in self._features:
+ if f.name == feature:
+ return f
+ elif isinstance(feature, type):
+ for f in self._features:
+ if isinstance(f, feature):
+ return f
+ else:
+ raise CruLogicError("Argument must be the name of feature or its class.")
+
+ raise AppFeatureError(f"Feature {feature} not found.", feature)
+
+ def get_path(self, name: str) -> AppFeaturePath:
+ for p in self._paths:
+ if p.id == name or p.name == name:
+ return p
+ raise AppPathError(f"Application path {name} not found.", name)
diff --git a/tools/cru-py/cru/service/_config.py b/tools/cru-py/cru/service/_config.py
new file mode 100644
index 0000000..b51e21c
--- /dev/null
+++ b/tools/cru-py/cru/service/_config.py
@@ -0,0 +1,446 @@
+from collections.abc import Iterable
+from typing import Any, Literal, overload
+
+from cru import CruException
+from cru.config import Configuration, ConfigItem
+from cru.value import (
+ INTEGER_VALUE_TYPE,
+ TEXT_VALUE_TYPE,
+ CruValueTypeError,
+ RandomStringValueGenerator,
+ UuidValueGenerator,
+)
+from cru.parsing import ParseError, SimpleLineConfigParser
+
+from ._base import AppFeaturePath, AppCommandFeatureProvider
+
+
+class AppConfigError(CruException):
+ def __init__(
+ self, message: str, configuration: Configuration, *args, **kwargs
+ ) -> None:
+ super().__init__(message, *args, **kwargs)
+ self._configuration = configuration
+
+ @property
+ def configuration(self) -> Configuration:
+ return self._configuration
+
+
+class AppConfigFileError(AppConfigError):
+ def __init__(
+ self,
+ message: str,
+ configuration: Configuration,
+ *args,
+ **kwargs,
+ ) -> None:
+ super().__init__(message, configuration, *args, **kwargs)
+
+
+class AppConfigFileNotFoundError(AppConfigFileError):
+ def __init__(
+ self,
+ message: str,
+ configuration: Configuration,
+ file_path: str,
+ *args,
+ **kwargs,
+ ) -> None:
+ super().__init__(message, configuration, *args, **kwargs)
+ self._file_path = file_path
+
+ @property
+ def file_path(self) -> str:
+ return self._file_path
+
+
+class AppConfigFileParseError(AppConfigFileError):
+ def __init__(
+ self,
+ message: str,
+ configuration: Configuration,
+ file_content: str,
+ *args,
+ **kwargs,
+ ) -> None:
+ super().__init__(message, configuration, *args, **kwargs)
+ self._file_content = file_content
+ self.__cause__: ParseError
+
+ @property
+ def file_content(self) -> str:
+ return self._file_content
+
+ def get_user_message(self) -> str:
+ return f"Error while parsing config file at line {self.__cause__.line_number}."
+
+
+class AppConfigFileEntryError(AppConfigFileError):
+ def __init__(
+ self,
+ message: str,
+ configuration: Configuration,
+ entries: Iterable[SimpleLineConfigParser.Entry],
+ *args,
+ **kwargs,
+ ) -> None:
+ super().__init__(message, configuration, *args, **kwargs)
+ self._entries = list(entries)
+
+ @property
+ def error_entries(self) -> list[SimpleLineConfigParser.Entry]:
+ return self._entries
+
+ @staticmethod
+ def entries_to_friendly_message(
+ entries: Iterable[SimpleLineConfigParser.Entry],
+ ) -> str:
+ return "\n".join(
+ f"line {entry.line_number}: {entry.key}={entry.value}" for entry in entries
+ )
+
+ @property
+ def friendly_message_head(self) -> str:
+ return "Error entries found in config file"
+
+ def get_user_message(self) -> str:
+ return (
+ f"{self.friendly_message_head}:\n"
+ f"{self.entries_to_friendly_message(self.error_entries)}"
+ )
+
+
+class AppConfigDuplicateEntryError(AppConfigFileEntryError):
+ @property
+ def friendly_message_head(self) -> str:
+ return "Duplicate entries found in config file"
+
+
+class AppConfigEntryValueFormatError(AppConfigFileEntryError):
+ @property
+ def friendly_message_head(self) -> str:
+ return "Invalid value format for entries"
+
+
+class AppConfigItemNotSetError(AppConfigError):
+ def __init__(
+ self,
+ message: str,
+ configuration: Configuration,
+ items: list[ConfigItem],
+ *args,
+ **kwargs,
+ ) -> None:
+ super().__init__(message, configuration, *args, **kwargs)
+ self._items = items
+
+
+class ConfigManager(AppCommandFeatureProvider):
+ def __init__(self) -> None:
+ super().__init__("config-manager")
+ configuration = Configuration()
+ self._configuration = configuration
+ self._loaded: bool = False
+ self._init_app_defined_items()
+
+ def _init_app_defined_items(self) -> None:
+ prefix = self.config_name_prefix
+
+ def _add_text(name: str, description: str) -> ConfigItem:
+ item = ConfigItem(f"{prefix}_{name}", description, TEXT_VALUE_TYPE)
+ self.configuration.add(item)
+ return item
+
+ def _add_uuid(name: str, description: str) -> ConfigItem:
+ item = ConfigItem(
+ f"{prefix}_{name}",
+ description,
+ TEXT_VALUE_TYPE,
+ default=UuidValueGenerator(),
+ )
+ self.configuration.add(item)
+ return item
+
+ def _add_random_string(
+ name: str, description: str, length: int = 32, secure: bool = True
+ ) -> ConfigItem:
+ item = ConfigItem(
+ f"{prefix}_{name}",
+ description,
+ TEXT_VALUE_TYPE,
+ default=RandomStringValueGenerator(length, secure),
+ )
+ self.configuration.add(item)
+ return item
+
+ def _add_int(name: str, description: str) -> ConfigItem:
+ item = ConfigItem(f"{prefix}_{name}", description, INTEGER_VALUE_TYPE)
+ self.configuration.add(item)
+ return item
+
+ self._domain = _add_text("DOMAIN", "domain name")
+ self._email = _add_text("EMAIL", "admin email address")
+ _add_text(
+ "AUTO_BACKUP_COS_SECRET_ID",
+ "access key id for Tencent COS, used for auto backup",
+ )
+ _add_text(
+ "AUTO_BACKUP_COS_SECRET_KEY",
+ "access key secret for Tencent COS, used for auto backup",
+ )
+ _add_text(
+ "AUTO_BACKUP_COS_REGION", "region for Tencent COS, used for auto backup"
+ )
+ _add_text(
+ "AUTO_BACKUP_BUCKET_NAME",
+ "bucket name for Tencent COS, used for auto backup",
+ )
+ _add_text("GITHUB_USERNAME", "github username for fetching todos")
+ _add_int("GITHUB_PROJECT_NUMBER", "github project number for fetching todos")
+ _add_text("GITHUB_TOKEN", "github token for fetching todos")
+ _add_text("GITHUB_TODO_COUNT", "github todo count")
+ _add_uuid("V2RAY_TOKEN", "v2ray user id")
+ _add_uuid("V2RAY_PATH", "v2ray path, which will be prefixed by _")
+ _add_text("FORGEJO_MAILER_USER", "Forgejo SMTP user")
+ _add_text("FORGEJO_MAILER_PASSWD", "Forgejo SMTP password")
+ _add_random_string("2FAUTH_APP_KEY", "2FAuth App Key")
+ _add_text("2FAUTH_MAIL_USERNAME", "2FAuth SMTP user")
+ _add_text("2FAUTH_MAIL_PASSWORD", "2FAuth SMTP password")
+
+ def setup(self) -> None:
+ self._config_file_path = self.app.data_dir.add_subpath(
+ "config", False, description="Configuration file path."
+ )
+
+ @property
+ def config_name_prefix(self) -> str:
+ return self.app.app_id.upper()
+
+ @property
+ def configuration(self) -> Configuration:
+ return self._configuration
+
+ @property
+ def config_file_path(self) -> AppFeaturePath:
+ return self._config_file_path
+
+ @property
+ def all_set(self) -> bool:
+ return self.configuration.all_set
+
+ def get_item(self, name: str) -> ConfigItem[Any]:
+ if not name.startswith(self.config_name_prefix + "_"):
+ name = f"{self.config_name_prefix}_{name}"
+
+ item = self.configuration.get_or(name, None)
+ if item is None:
+ raise AppConfigError(f"Config item '{name}' not found.", self.configuration)
+ return item
+
+ @overload
+ def get_item_value_str(self, name: str) -> str: ...
+
+ @overload
+ def get_item_value_str(self, name: str, ensure_set: Literal[True]) -> str: ...
+
+ @overload
+ def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None: ...
+
+ def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None:
+ self.load_config_file()
+ item = self.get_item(name)
+ if not item.is_set:
+ if ensure_set:
+ raise AppConfigItemNotSetError(
+ f"Config item '{name}' is not set.", self.configuration, [item]
+ )
+ return None
+ return item.value_str
+
+ def get_str_dict(self, ensure_all_set: bool = True) -> dict[str, str]:
+ self.load_config_file()
+ if ensure_all_set and not self.configuration.all_set:
+ raise AppConfigItemNotSetError(
+ "Some config items are not set.",
+ self.configuration,
+ self.configuration.get_unset_items(),
+ )
+ return self.configuration.to_str_dict()
+
+ @property
+ def domain_item_name(self) -> str:
+ return self._domain.name
+
+ def get_domain_value_str(self) -> str:
+ return self.get_item_value_str(self._domain.name)
+
+ def get_email_value_str_optional(self) -> str | None:
+ return self.get_item_value_str(self._email.name, ensure_set=False)
+
+ def _set_with_default(self) -> None:
+ if not self.configuration.all_not_set:
+ raise AppConfigError(
+ "Config is not clean. "
+ "Some config items are already set. "
+ "Can't set again with default value.",
+ self.configuration,
+ )
+ for item in self.configuration:
+ if item.can_generate_default:
+ item.set_value(item.generate_default_value())
+
+ def _to_config_file_content(self) -> str:
+ content = "".join(
+ [
+ f"{item.name}={item.value_str if item.is_set else ''}\n"
+ for item in self.configuration
+ ]
+ )
+ return content
+
+ def _create_init_config_file(self) -> None:
+ if self.config_file_path.check_self():
+ raise AppConfigError(
+ "Config file already exists.",
+ self.configuration,
+ user_message=f"The config file at "
+ f"{self.config_file_path.full_path_str} already exists.",
+ )
+ self._set_with_default()
+ self.config_file_path.ensure()
+ with open(
+ self.config_file_path.full_path, "w", encoding="utf-8", newline="\n"
+ ) as file:
+ file.write(self._to_config_file_content())
+
+ def _parse_config_file(self) -> SimpleLineConfigParser.Result:
+ if not self.config_file_path.check_self():
+ raise AppConfigFileNotFoundError(
+ "Config file not found.",
+ self.configuration,
+ self.config_file_path.full_path_str,
+ user_message=f"The config file at "
+ f"{self.config_file_path.full_path_str} does not exist. "
+ f"You can create an initial one with 'init' command.",
+ )
+
+ text = self.config_file_path.full_path.read_text()
+ try:
+ parser = SimpleLineConfigParser()
+ return parser.parse(text)
+ except ParseError as e:
+ raise AppConfigFileParseError(
+ "Failed to parse config file.", self.configuration, text
+ ) from e
+
+ def _parse_and_print_config_file(self) -> None:
+ parse_result = self._parse_config_file()
+ for entry in parse_result:
+ print(f"{entry.key}={entry.value}")
+
+ def _check_duplicate(
+ self,
+ parse_result: dict[str, list[SimpleLineConfigParser.Entry]],
+ ) -> dict[str, SimpleLineConfigParser.Entry]:
+ entry_dict: dict[str, SimpleLineConfigParser.Entry] = {}
+ duplicate_entries: list[SimpleLineConfigParser.Entry] = []
+ for key, entries in parse_result.items():
+ entry_dict[key] = entries[0]
+ if len(entries) > 1:
+ duplicate_entries.extend(entries)
+ if len(duplicate_entries) > 0:
+ raise AppConfigDuplicateEntryError(
+ "Duplicate entries found.", self.configuration, duplicate_entries
+ )
+
+ return entry_dict
+
+ def _check_type(
+ self, entry_dict: dict[str, SimpleLineConfigParser.Entry]
+ ) -> dict[str, Any]:
+ value_dict: dict[str, Any] = {}
+ error_entries: list[SimpleLineConfigParser.Entry] = []
+ errors: list[CruValueTypeError] = []
+ for key, entry in entry_dict.items():
+ config_item = self.configuration.get(key)
+ try:
+ if entry.value == "":
+ value_dict[key] = None
+ else:
+ value_dict[key] = config_item.value_type.convert_str_to_value(
+ entry.value
+ )
+ except CruValueTypeError as e:
+ error_entries.append(entry)
+ errors.append(e)
+ if len(error_entries) > 0:
+ raise AppConfigEntryValueFormatError(
+ "Entry value format is not correct.",
+ self.configuration,
+ error_entries,
+ ) from ExceptionGroup("Multiple format errors occurred.", errors)
+ return value_dict
+
+ def _read_config_file(self) -> dict[str, Any]:
+ parsed = self._parse_config_file()
+ entry_groups = parsed.cru_iter().group_by(lambda e: e.key)
+ entry_dict = self._check_duplicate(entry_groups)
+ value_dict = self._check_type(entry_dict)
+ return value_dict
+
+ def _real_load_config_file(self) -> None:
+ self.configuration.reset_all()
+ value_dict = self._read_config_file()
+ for key, value in value_dict.items():
+ if value is None:
+ continue
+ self.configuration.set_config_item(key, value)
+
+ def load_config_file(self, force=False) -> None:
+ if force or not self._loaded:
+ self._real_load_config_file()
+ self._loaded = True
+
+ def _print_app_config_info(self):
+ for item in self.configuration:
+ print(item.description_str)
+
+ def get_command_info(self):
+ return "config", "Manage configuration."
+
+ def setup_arg_parser(self, arg_parser) -> None:
+ subparsers = arg_parser.add_subparsers(
+ dest="config_command", required=True, metavar="CONFIG_COMMAND"
+ )
+ _init_parser = subparsers.add_parser(
+ "init", help="create an initial config file"
+ )
+ _print_app_parser = subparsers.add_parser(
+ "print-app",
+ help="print information of the config items defined by app",
+ )
+ _print_parser = subparsers.add_parser("print", help="print current config")
+ _check_config_parser = subparsers.add_parser(
+ "check",
+ help="check the validity of the config file",
+ )
+ _check_config_parser.add_argument(
+ "-f",
+ "--format-only",
+ action="store_true",
+ help="only check content format, not app config item requirements.",
+ )
+
+ def run_command(self, args) -> None:
+ if args.config_command == "init":
+ self._create_init_config_file()
+ elif args.config_command == "print-app":
+ self._print_app_config_info()
+ elif args.config_command == "print":
+ self._parse_and_print_config_file()
+ elif args.config_command == "check":
+ if args.format_only:
+ self._parse_config_file()
+ else:
+ self._read_config_file()
diff --git a/tools/cru-py/cru/service/_external.py b/tools/cru-py/cru/service/_external.py
new file mode 100644
index 0000000..2347e95
--- /dev/null
+++ b/tools/cru-py/cru/service/_external.py
@@ -0,0 +1,81 @@
+from ._base import AppCommandFeatureProvider
+from ._nginx import NginxManager
+
+
+class CliToolCommandProvider(AppCommandFeatureProvider):
+ def __init__(self) -> None:
+ super().__init__("cli-tool-command-provider")
+
+ def setup(self):
+ pass
+
+ def get_command_info(self):
+ return ("gen-cli", "Get commands of running external cli tools.")
+
+ def setup_arg_parser(self, arg_parser):
+ subparsers = arg_parser.add_subparsers(
+ dest="gen_cli_command", required=True, metavar="GEN_CLI_COMMAND"
+ )
+ certbot_parser = subparsers.add_parser("certbot", help="print certbot commands")
+ certbot_parser.add_argument(
+ "-t", "--test", action="store_true", help="run certbot in test mode"
+ )
+ _install_docker_parser = subparsers.add_parser(
+ "install-docker", help="print docker installation commands"
+ )
+ _update_blog_parser = subparsers.add_parser(
+ "update-blog", help="print blog update command"
+ )
+
+ def _print_install_docker_commands(self) -> None:
+ output = """
+### COMMAND: uninstall apt docker
+for pkg in docker.io docker-doc docker-compose \
+podman-docker containerd runc; \
+do sudo apt-get remove $pkg; done
+
+### COMMAND: prepare apt certs
+sudo apt-get update
+sudo apt-get install ca-certificates curl
+sudo install -m 0755 -d /etc/apt/keyrings
+
+### COMMAND: install certs
+sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
+-o /etc/apt/keyrings/docker.asc
+sudo chmod a+r /etc/apt/keyrings/docker.asc
+
+### COMMAND: add docker apt source
+echo \\
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
+https://download.docker.com/linux/debian \\
+ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \\
+ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+### COMMAND: update apt and install docker
+sudo apt-get update
+sudo apt-get install docker-ce docker-ce-cli containerd.io \
+docker-buildx-plugin docker-compose-plugin
+
+### COMMAND: setup system for docker
+sudo systemctl enable docker
+sudo systemctl start docker
+sudo groupadd -f docker
+sudo usermod -aG docker $USER
+# Remember to log out and log back in for the group changes to take effect
+""".strip()
+ print(output)
+
+ def _print_update_blog_command(self):
+ output = """
+### COMMAND: update blog
+docker exec -it blog /scripts/update.bash
+""".strip()
+ print(output)
+
+ def run_command(self, args):
+ if args.gen_cli_command == "certbot":
+ self.app.get_feature(NginxManager).print_all_certbot_commands(args.test)
+ elif args.gen_cli_command == "install-docker":
+ self._print_install_docker_commands()
+ elif args.gen_cli_command == "update-blog":
+ self._print_update_blog_command() \ No newline at end of file
diff --git a/tools/cru-py/cru/service/_nginx.py b/tools/cru-py/cru/service/_nginx.py
new file mode 100644
index 0000000..e0a9c60
--- /dev/null
+++ b/tools/cru-py/cru/service/_nginx.py
@@ -0,0 +1,281 @@
+from argparse import Namespace
+from enum import Enum, auto
+import re
+import subprocess
+from typing import TypeAlias
+
+from cru import CruInternalError
+
+from ._base import AppCommandFeatureProvider
+from ._config import ConfigManager
+from ._template import TemplateManager
+
+
+class CertbotAction(Enum):
+ CREATE = auto()
+ EXPAND = auto()
+ SHRINK = auto()
+ RENEW = auto()
+
+
+class NginxManager(AppCommandFeatureProvider):
+ CertbotAction: TypeAlias = CertbotAction
+
+ def __init__(self) -> None:
+ super().__init__("nginx-manager")
+ self._domains_cache: list[str] | None = None
+
+ def setup(self) -> None:
+ pass
+
+ @property
+ def _config_manager(self) -> ConfigManager:
+ return self.app.get_feature(ConfigManager)
+
+ @property
+ def root_domain(self) -> str:
+ return self._config_manager.get_domain_value_str()
+
+ @property
+ def domains(self) -> list[str]:
+ if self._domains_cache is None:
+ self._domains_cache = self._get_domains()
+ return self._domains_cache
+
+ @property
+ def subdomains(self) -> list[str]:
+ suffix = "." + self.root_domain
+ return [d[: -len(suffix)] for d in self.domains if d.endswith(suffix)]
+
+ @property
+ def _domain_config_name(self) -> str:
+ return self._config_manager.domain_item_name
+
+ def _get_domains_from_text(self, text: str) -> set[str]:
+ domains: set[str] = set()
+ regex = re.compile(r"server_name\s+(\S+)\s*;")
+ domain_variable_str = f"${self._domain_config_name}"
+ brace_domain_variable_regex = re.compile(
+ r"\$\{\s*" + self._domain_config_name + r"\s*\}"
+ )
+ for match in regex.finditer(text):
+ domain_part = match.group(1)
+ if domain_variable_str in domain_part:
+ domains.add(domain_part.replace(domain_variable_str, self.root_domain))
+ continue
+ m = brace_domain_variable_regex.search(domain_part)
+ if m:
+ domains.add(domain_part.replace(m.group(0), self.root_domain))
+ continue
+ domains.add(domain_part)
+ return domains
+
+ def _get_nginx_conf_template_text(self) -> str:
+ template_manager = self.app.get_feature(TemplateManager)
+ text = ""
+ for path, template in template_manager.template_tree.templates:
+ if path.as_posix().startswith("nginx/"):
+ text += template.raw_text
+ return text
+
+ def _get_domains(self) -> list[str]:
+ text = self._get_nginx_conf_template_text()
+ domains = list(self._get_domains_from_text(text))
+ domains.remove(self.root_domain)
+ return [self.root_domain, *domains]
+
+ def _print_domains(self) -> None:
+ for domain in self.domains:
+ print(domain)
+
+ def _certbot_command(
+ self,
+ action: CertbotAction | str,
+ test: bool,
+ *,
+ docker=True,
+ standalone=None,
+ email=None,
+ agree_tos=True,
+ ) -> str:
+ if isinstance(action, str):
+ action = CertbotAction[action.upper()]
+
+ command_args = []
+
+ add_domain_option = True
+ if action is CertbotAction.CREATE:
+ if standalone is None:
+ standalone = True
+ command_action = "certonly"
+ elif action in [CertbotAction.EXPAND, CertbotAction.SHRINK]:
+ if standalone is None:
+ standalone = False
+ command_action = "certonly"
+ elif action is CertbotAction.RENEW:
+ if standalone is None:
+ standalone = False
+ add_domain_option = False
+ command_action = "renew"
+ else:
+ raise CruInternalError("Invalid certbot action.")
+
+ data_dir = self.app.data_dir.full_path.as_posix()
+
+ if not docker:
+ command_args.append("certbot")
+ else:
+ command_args.extend(
+ [
+ "docker run -it --rm --name certbot",
+ f'-v "{data_dir}/certbot/certs:/etc/letsencrypt"',
+ f'-v "{data_dir}/certbot/data:/var/lib/letsencrypt"',
+ ]
+ )
+ if standalone:
+ command_args.append('-p "0.0.0.0:80:80"')
+ else:
+ command_args.append(f'-v "{data_dir}/certbot/webroot:/var/www/certbot"')
+
+ command_args.append("certbot/certbot")
+
+ command_args.append(command_action)
+
+ command_args.append(f"--cert-name {self.root_domain}")
+
+ if standalone:
+ command_args.append("--standalone")
+ else:
+ command_args.append("--webroot -w /var/www/certbot")
+
+ if add_domain_option:
+ command_args.append(" ".join([f"-d {domain}" for domain in self.domains]))
+
+ if email is not None:
+ command_args.append(f"--email {email}")
+
+ if agree_tos:
+ command_args.append("--agree-tos")
+
+ if test:
+ command_args.append("--test-cert --dry-run")
+
+ return " ".join(command_args)
+
+ def print_all_certbot_commands(self, test: bool):
+ print("### COMMAND: (standalone) create certs")
+ print(
+ self._certbot_command(
+ CertbotAction.CREATE,
+ test,
+ email=self._config_manager.get_email_value_str_optional(),
+ )
+ )
+ print()
+ print("### COMMAND: (webroot+nginx) expand or shrink certs")
+ print(
+ self._certbot_command(
+ CertbotAction.EXPAND,
+ test,
+ email=self._config_manager.get_email_value_str_optional(),
+ )
+ )
+ print()
+ print("### COMMAND: (webroot+nginx) renew certs")
+ print(
+ self._certbot_command(
+ CertbotAction.RENEW,
+ test,
+ email=self._config_manager.get_email_value_str_optional(),
+ )
+ )
+
+ @property
+ def _cert_path_str(self) -> str:
+ return str(
+ self.app.data_dir.full_path
+ / "certbot/certs/live"
+ / self.root_domain
+ / "fullchain.pem"
+ )
+
+ def get_command_info(self):
+ return "nginx", "Manage nginx related things."
+
+ def setup_arg_parser(self, arg_parser):
+ subparsers = arg_parser.add_subparsers(
+ dest="nginx_command", required=True, metavar="NGINX_COMMAND"
+ )
+ _list_parser = subparsers.add_parser("list", help="list domains")
+ certbot_parser = subparsers.add_parser("certbot", help="print certbot commands")
+ certbot_parser.add_argument(
+ "--no-test",
+ action="store_true",
+ help="remove args making certbot run in test mode",
+ )
+
+ def run_command(self, args: Namespace) -> None:
+ if args.nginx_command == "list":
+ self._print_domains()
+ elif args.nginx_command == "certbot":
+ self.print_all_certbot_commands(not args.no_test)
+
+ def _generate_dns_zone(
+ self,
+ ip: str,
+ /,
+ ttl: str | int = 600,
+ *,
+ enable_mail: bool = True,
+ dkim: str | None = None,
+ ) -> str:
+ # TODO: Not complete and test now.
+ root_domain = self.root_domain
+ result = f"$ORIGIN {root_domain}.\n\n"
+ result += "; A records\n"
+ result += f"@ {ttl} IN A {ip}\n"
+ for subdomain in self.subdomains:
+ result += f"{subdomain} {ttl} IN A {ip}\n"
+
+ if enable_mail:
+ result += "\n; MX records\n"
+ result += f"@ {ttl} IN MX 10 mail.{root_domain}.\n"
+ result += "\n; SPF record\n"
+ result += f'@ {ttl} IN TXT "v=spf1 mx ~all"\n'
+ if dkim is not None:
+ result += "\n; DKIM record\n"
+ result += f'mail._domainkey {ttl} IN TEXT "{dkim}"'
+ result += "\n; DMARC record\n"
+ dmarc_options = [
+ "v=DMARC1",
+ "p=none",
+ f"rua=mailto:dmarc.report@{root_domain}",
+ f"ruf=mailto:dmarc.report@{root_domain}",
+ "sp=none",
+ "ri=86400",
+ ]
+ result += f'_dmarc {ttl} IN TXT "{"; ".join(dmarc_options)}"\n'
+ return result
+
+ def _get_dkim_from_mailserver(self) -> str | None:
+ # TODO: Not complete and test now.
+ dkim_path = (
+ self.app.data_dir.full_path
+ / "dms/config/opendkim/keys"
+ / self.root_domain
+ / "mail.txt"
+ )
+ if not dkim_path.exists():
+ return None
+
+ p = subprocess.run(["sudo", "cat", dkim_path], capture_output=True, check=True)
+ value = ""
+ for match in re.finditer('"(.*)"', p.stdout.decode("utf-8")):
+ value += match.group(1)
+ return value
+
+ def _generate_dns_zone_with_dkim(self, ip: str, /, ttl: str | int = 600) -> str:
+ # TODO: Not complete and test now.
+ return self._generate_dns_zone(
+ ip, ttl, enable_mail=True, dkim=self._get_dkim_from_mailserver()
+ )
diff --git a/tools/cru-py/cru/service/_template.py b/tools/cru-py/cru/service/_template.py
new file mode 100644
index 0000000..170116c
--- /dev/null
+++ b/tools/cru-py/cru/service/_template.py
@@ -0,0 +1,86 @@
+from argparse import Namespace
+import shutil
+
+from cru import CruIterator
+from cru.template import TemplateTree
+
+from ._base import AppCommandFeatureProvider, AppFeaturePath
+from ._config import ConfigManager
+
+
+class TemplateManager(AppCommandFeatureProvider):
+ def __init__(self, prefix: str | None = None):
+ super().__init__("template-manager")
+ self._prefix = prefix or self.app.app_id.upper()
+
+ def setup(self) -> None:
+ self._templates_dir = self.app.add_path("templates", True)
+ self._generated_dir = self.app.add_path("generated", True)
+ self._template_tree: TemplateTree | None = None
+
+ @property
+ def prefix(self) -> str:
+ return self._prefix
+
+ @property
+ def templates_dir(self) -> AppFeaturePath:
+ return self._templates_dir
+
+ @property
+ def generated_dir(self) -> AppFeaturePath:
+ return self._generated_dir
+
+ @property
+ def template_tree(self) -> TemplateTree:
+ if self._template_tree is None:
+ return self.reload()
+ return self._template_tree
+
+ def reload(self) -> TemplateTree:
+ self._template_tree = TemplateTree(
+ self.prefix, self.templates_dir.full_path_str
+ )
+ return self._template_tree
+
+ def _print_file_lists(self) -> None:
+ for file in CruIterator(self.template_tree.templates).transform(lambda t: t[0]):
+ print(file.as_posix())
+
+ def _generate_files(self, dry_run: bool) -> None:
+ config_manager = self.app.get_feature(ConfigManager)
+ if not dry_run and self.generated_dir.full_path.exists():
+ shutil.rmtree(self.generated_dir.full_path)
+ self.template_tree.generate_to(
+ self.generated_dir.full_path_str, config_manager.get_str_dict(), dry_run
+ )
+
+ def get_command_info(self):
+ return ("template", "Manage templates.")
+
+ def setup_arg_parser(self, arg_parser):
+ subparsers = arg_parser.add_subparsers(
+ dest="template_command", required=True, metavar="TEMPLATE_COMMAND"
+ )
+ _list_parser = subparsers.add_parser("list", help="list templates")
+ _variables_parser = subparsers.add_parser(
+ "variables", help="list variables used in all templates"
+ )
+ generate_parser = subparsers.add_parser("generate", help="generate templates")
+ generate_parser.add_argument(
+ "--no-dry-run", action="store_true", help="generate and write target files"
+ )
+
+ def run_command(self, args: Namespace) -> None:
+ if args.template_command == "list":
+ self._print_file_lists()
+ elif args.template_command == "variables":
+ for var in self.template_tree.variables:
+ print(var)
+ elif args.template_command == "generate":
+ dry_run = not args.no_dry_run
+ self._generate_files(dry_run)
+ if dry_run:
+ print("Dry run successfully.")
+ print(
+ f"Will delete dir {self.generated_dir.full_path_str} if it exists."
+ )
diff --git a/tools/cru-py/cru/system.py b/tools/cru-py/cru/system.py
new file mode 100644
index 0000000..f321717
--- /dev/null
+++ b/tools/cru-py/cru/system.py
@@ -0,0 +1,23 @@
+import os.path
+import re
+
+
+def check_debian_derivative_version(name: str) -> None | str:
+ if not os.path.isfile("/etc/os-release"):
+ return None
+ with open("/etc/os-release", "r") as f:
+ content = f.read()
+ if f"ID={name}" not in content:
+ return None
+ m = re.search(r'VERSION_ID="(.+)"', content)
+ if m is None:
+ return None
+ return m.group(1)
+
+
+def check_ubuntu_version() -> None | str:
+ return check_debian_derivative_version("ubuntu")
+
+
+def check_debian_version() -> None | str:
+ return check_debian_derivative_version("debian")
diff --git a/tools/cru-py/cru/template.py b/tools/cru-py/cru/template.py
new file mode 100644
index 0000000..6749cab
--- /dev/null
+++ b/tools/cru-py/cru/template.py
@@ -0,0 +1,153 @@
+from collections.abc import Mapping
+import os
+import os.path
+from pathlib import Path
+from string import Template
+
+from ._iter import CruIterator
+from ._error import CruException
+
+
+class CruTemplateError(CruException):
+ pass
+
+
+class CruTemplate:
+ def __init__(self, prefix: str, text: str):
+ self._prefix = prefix
+ self._template = Template(text)
+ self._variables = (
+ CruIterator(self._template.get_identifiers())
+ .filter(lambda i: i.startswith(self._prefix))
+ .to_set()
+ )
+ self._all_variables = set(self._template.get_identifiers())
+
+ @property
+ def prefix(self) -> str:
+ return self._prefix
+
+ @property
+ def raw_text(self) -> str:
+ return self._template.template
+
+ @property
+ def py_template(self) -> Template:
+ return self._template
+
+ @property
+ def variables(self) -> set[str]:
+ return self._variables
+
+ @property
+ def all_variables(self) -> set[str]:
+ return self._all_variables
+
+ @property
+ def has_variables(self) -> bool:
+ """
+ If the template does not has any variables that starts with the given prefix,
+ it returns False. This usually indicates that the template is not a real
+ template and should be copied as is. Otherwise, it returns True.
+
+ This can be used as a guard to prevent invalid templates created accidentally
+ without notice.
+ """
+ return len(self.variables) > 0
+
+ def generate(self, mapping: Mapping[str, str], allow_extra: bool = True) -> str:
+ values = dict(mapping)
+ if not self.variables <= set(values.keys()):
+ raise CruTemplateError("Missing variables.")
+ if not allow_extra and not set(values.keys()) <= self.variables:
+ raise CruTemplateError("Extra variables.")
+ return self._template.safe_substitute(values)
+
+
+class TemplateTree:
+ def __init__(
+ self,
+ prefix: str,
+ source: str,
+ template_file_suffix: str | None = ".template",
+ ):
+ """
+ If template_file_suffix is not None, the files will be checked according to the
+ suffix of the file name. If the suffix matches, the file will be regarded as a
+ template file. Otherwise, it will be regarded as a non-template file.
+ Content of template file must contain variables that need to be replaced, while
+ content of non-template file may not contain any variables.
+ If either case is false, it generally means whether the file is a template is
+ wrongly handled.
+ """
+ self._prefix = prefix
+ self._files: list[tuple[Path, CruTemplate]] = []
+ self._source = source
+ self._template_file_suffix = template_file_suffix
+ self._load()
+
+ @property
+ def prefix(self) -> str:
+ return self._prefix
+
+ @property
+ def templates(self) -> list[tuple[Path, CruTemplate]]:
+ return self._files
+
+ @property
+ def source(self) -> str:
+ return self._source
+
+ @property
+ def template_file_suffix(self) -> str | None:
+ return self._template_file_suffix
+
+ @staticmethod
+ def _scan_files(root_path: str) -> list[Path]:
+ result: list[Path] = []
+ for root, _dirs, files in os.walk(root_path):
+ for file in files:
+ path = Path(root, file)
+ path = path.relative_to(root_path)
+ result.append(Path(path))
+ return result
+
+ def _load(self) -> None:
+ files = self._scan_files(self.source)
+ for file_path in files:
+ template_file = Path(self.source) / file_path
+ with open(template_file, "r") as f:
+ content = f.read()
+ template = CruTemplate(self.prefix, content)
+ if self.template_file_suffix is not None:
+ should_be_template = file_path.name.endswith(self.template_file_suffix)
+ if should_be_template and not template.has_variables:
+ raise CruTemplateError(
+ f"Template file {file_path} has no variables."
+ )
+ elif not should_be_template and template.has_variables:
+ raise CruTemplateError(f"Non-template {file_path} has variables.")
+ self._files.append((file_path, template))
+
+ @property
+ def variables(self) -> set[str]:
+ s = set()
+ for _, template in self.templates:
+ s.update(template.variables)
+ return s
+
+ def generate_to(
+ self, destination: str, variables: Mapping[str, str], dry_run: bool
+ ) -> None:
+ for file, template in self.templates:
+ des = Path(destination) / file
+ if self.template_file_suffix is not None and des.name.endswith(
+ self.template_file_suffix
+ ):
+ des = des.parent / (des.name[: -len(self.template_file_suffix)])
+
+ text = template.generate(variables)
+ if not dry_run:
+ des.parent.mkdir(parents=True, exist_ok=True)
+ with open(des, "w") as f:
+ f.write(text)
diff --git a/tools/cru-py/cru/tool.py b/tools/cru-py/cru/tool.py
new file mode 100644
index 0000000..377f5d7
--- /dev/null
+++ b/tools/cru-py/cru/tool.py
@@ -0,0 +1,82 @@
+import shutil
+import subprocess
+from typing import Any
+from collections.abc import Iterable
+
+from ._error import CruException
+
+
+class CruExternalToolError(CruException):
+ def __init__(self, message: str, tool: str, *args, **kwargs) -> None:
+ super().__init__(message, *args, **kwargs)
+ self._tool = tool
+
+ @property
+ def tool(self) -> str:
+ return self._tool
+
+
+class CruExternalToolNotFoundError(CruExternalToolError):
+ def __init__(self, message: str | None, tool: str, *args, **kwargs) -> None:
+ super().__init__(
+ message or f"Could not find binary for {tool}.", tool, *args, **kwargs
+ )
+
+
+class CruExternalToolRunError(CruExternalToolError):
+ def __init__(
+ self,
+ message: str,
+ tool: str,
+ tool_args: Iterable[str],
+ tool_error: Any,
+ *args,
+ **kwargs,
+ ) -> None:
+ super().__init__(message, tool, *args, **kwargs)
+ self._tool_args = list(tool_args)
+ self._tool_error = tool_error
+
+ @property
+ def tool_args(self) -> list[str]:
+ return self._tool_args
+
+ @property
+ def tool_error(self) -> Any:
+ return self._tool_error
+
+
+class ExternalTool:
+ def __init__(self, bin: str) -> None:
+ self._bin = bin
+
+ @property
+ def bin(self) -> str:
+ return self._bin
+
+ @bin.setter
+ def bin(self, value: str) -> None:
+ self._bin = value
+
+ @property
+ def bin_path(self) -> str:
+ real_bin = shutil.which(self.bin)
+ if not real_bin:
+ raise CruExternalToolNotFoundError(None, self.bin)
+ return real_bin
+
+ def run(
+ self, *process_args: str, **subprocess_kwargs
+ ) -> subprocess.CompletedProcess:
+ try:
+ return subprocess.run(
+ [self.bin_path] + list(process_args), **subprocess_kwargs
+ )
+ except subprocess.CalledProcessError as e:
+ raise CruExternalToolError("Subprocess failed.", self.bin) from e
+ except OSError as e:
+ raise CruExternalToolError("Failed to start subprocess", self.bin) from e
+
+ def run_get_output(self, *process_args: str, **subprocess_kwargs) -> Any:
+ process = self.run(*process_args, capture_output=True, **subprocess_kwargs)
+ return process.stdout
diff --git a/tools/cru-py/cru/value.py b/tools/cru-py/cru/value.py
new file mode 100644
index 0000000..9c03219
--- /dev/null
+++ b/tools/cru-py/cru/value.py
@@ -0,0 +1,292 @@
+from __future__ import annotations
+
+import random
+import secrets
+import string
+import uuid
+from abc import abstractmethod, ABCMeta
+from collections.abc import Callable
+from typing import Any, ClassVar, TypeVar, Generic
+
+from ._error import CruException
+
+
+def _str_case_in(s: str, case: bool, str_list: list[str]) -> bool:
+ if case:
+ return s in str_list
+ else:
+ return s.lower() in [s.lower() for s in str_list]
+
+
+_T = TypeVar("_T")
+
+
+class CruValueTypeError(CruException):
+ def __init__(
+ self,
+ message: str,
+ value: Any,
+ value_type: ValueType | None,
+ *args,
+ **kwargs,
+ ):
+ super().__init__(
+ message,
+ *args,
+ **kwargs,
+ )
+ self._value = value
+ self._value_type = value_type
+
+ @property
+ def value(self) -> Any:
+ return self._value
+
+ @property
+ def value_type(self) -> ValueType | None:
+ return self._value_type
+
+
+class ValueType(Generic[_T], metaclass=ABCMeta):
+ def __init__(self, name: str, _type: type[_T]) -> None:
+ self._name = name
+ self._type = _type
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def type(self) -> type[_T]:
+ return self._type
+
+ def check_value_type(self, value: Any) -> None:
+ if not isinstance(value, self.type):
+ raise CruValueTypeError("Type of value is wrong.", value, self)
+
+ def _do_check_value(self, value: Any) -> _T:
+ return value
+
+ def check_value(self, value: Any) -> _T:
+ self.check_value_type(value)
+ return self._do_check_value(value)
+
+ @abstractmethod
+ def _do_check_str_format(self, s: str) -> None:
+ raise NotImplementedError()
+
+ def check_str_format(self, s: str) -> None:
+ if not isinstance(s, str):
+ raise CruValueTypeError("Try to check format on a non-str.", s, self)
+ self._do_check_str_format(s)
+
+ @abstractmethod
+ def _do_convert_value_to_str(self, value: _T) -> str:
+ raise NotImplementedError()
+
+ def convert_value_to_str(self, value: _T) -> str:
+ self.check_value(value)
+ return self._do_convert_value_to_str(value)
+
+ @abstractmethod
+ def _do_convert_str_to_value(self, s: str) -> _T:
+ raise NotImplementedError()
+
+ def convert_str_to_value(self, s: str) -> _T:
+ self.check_str_format(s)
+ return self._do_convert_str_to_value(s)
+
+ def check_value_or_try_convert_from_str(self, value_or_str: Any) -> _T:
+ try:
+ return self.check_value(value_or_str)
+ except CruValueTypeError:
+ if isinstance(value_or_str, str):
+ return self.convert_str_to_value(value_or_str)
+ else:
+ raise
+
+ def create_default_value(self) -> _T:
+ return self.type()
+
+
+class TextValueType(ValueType[str]):
+ def __init__(self) -> None:
+ super().__init__("text", str)
+
+ def _do_check_str_format(self, _s):
+ return
+
+ def _do_convert_value_to_str(self, value):
+ return value
+
+ def _do_convert_str_to_value(self, s):
+ return s
+
+
+class IntegerValueType(ValueType[int]):
+ def __init__(self) -> None:
+ super().__init__("integer", int)
+
+ def _do_check_str_format(self, s):
+ try:
+ int(s)
+ except ValueError as e:
+ raise CruValueTypeError("Invalid integer format.", s, self) from e
+
+ def _do_convert_value_to_str(self, value):
+ return str(value)
+
+ def _do_convert_str_to_value(self, s):
+ return int(s)
+
+
+class FloatValueType(ValueType[float]):
+ def __init__(self) -> None:
+ super().__init__("float", float)
+
+ def _do_check_str_format(self, s):
+ try:
+ float(s)
+ except ValueError as e:
+ raise CruValueTypeError("Invalid float format.", s, self) from e
+
+ def _do_convert_value_to_str(self, value):
+ return str(value)
+
+ def _do_convert_str_to_value(self, s):
+ return float(s)
+
+
+class BooleanValueType(ValueType[bool]):
+ DEFAULT_TRUE_LIST: ClassVar[list[str]] = ["true", "yes", "y", "on", "1"]
+ DEFAULT_FALSE_LIST: ClassVar[list[str]] = ["false", "no", "n", "off", "0"]
+
+ def __init__(
+ self,
+ *,
+ case_sensitive=False,
+ true_list: None | list[str] = None,
+ false_list: None | list[str] = None,
+ ) -> None:
+ super().__init__("boolean", bool)
+ self._case_sensitive = case_sensitive
+ self._valid_true_strs: list[str] = (
+ true_list or BooleanValueType.DEFAULT_TRUE_LIST
+ )
+ self._valid_false_strs: list[str] = (
+ false_list or BooleanValueType.DEFAULT_FALSE_LIST
+ )
+
+ @property
+ def case_sensitive(self) -> bool:
+ return self._case_sensitive
+
+ @property
+ def valid_true_strs(self) -> list[str]:
+ return self._valid_true_strs
+
+ @property
+ def valid_false_strs(self) -> list[str]:
+ return self._valid_false_strs
+
+ @property
+ def valid_boolean_strs(self) -> list[str]:
+ return self._valid_true_strs + self._valid_false_strs
+
+ def _do_check_str_format(self, s):
+ if not _str_case_in(s, self.case_sensitive, self.valid_boolean_strs):
+ raise CruValueTypeError("Invalid boolean format.", s, self)
+
+ def _do_convert_value_to_str(self, value):
+ return self._valid_true_strs[0] if value else self._valid_false_strs[0]
+
+ def _do_convert_str_to_value(self, s):
+ return _str_case_in(s, self.case_sensitive, self._valid_true_strs)
+
+ def create_default_value(self):
+ return self.valid_false_strs[0]
+
+
+class EnumValueType(ValueType[str]):
+ def __init__(self, valid_values: list[str], /, case_sensitive=False) -> None:
+ super().__init__(f"enum({'|'.join(valid_values)})", str)
+ self._case_sensitive = case_sensitive
+ self._valid_values = valid_values
+
+ @property
+ def case_sensitive(self) -> bool:
+ return self._case_sensitive
+
+ @property
+ def valid_values(self) -> list[str]:
+ return self._valid_values
+
+ def _do_check_value(self, value):
+ self._do_check_str_format(value)
+
+ def _do_check_str_format(self, s):
+ if not _str_case_in(s, self.case_sensitive, self.valid_values):
+ raise CruValueTypeError("Invalid enum value", s, self)
+
+ def _do_convert_value_to_str(self, value):
+ return value
+
+ def _do_convert_str_to_value(self, s):
+ return s
+
+ def create_default_value(self):
+ return self.valid_values[0]
+
+
+TEXT_VALUE_TYPE = TextValueType()
+INTEGER_VALUE_TYPE = IntegerValueType()
+BOOLEAN_VALUE_TYPE = BooleanValueType()
+
+
+class ValueGeneratorBase(Generic[_T], metaclass=ABCMeta):
+ @abstractmethod
+ def generate(self) -> _T:
+ raise NotImplementedError()
+
+ def __call__(self) -> _T:
+ return self.generate()
+
+
+class ValueGenerator(ValueGeneratorBase[_T]):
+ def __init__(self, generate_func: Callable[[], _T]) -> None:
+ self._generate_func = generate_func
+
+ @property
+ def generate_func(self) -> Callable[[], _T]:
+ return self._generate_func
+
+ def generate(self) -> _T:
+ return self._generate_func()
+
+
+class UuidValueGenerator(ValueGeneratorBase[str]):
+ def generate(self):
+ return str(uuid.uuid4())
+
+
+class RandomStringValueGenerator(ValueGeneratorBase[str]):
+ def __init__(self, length: int, secure: bool) -> None:
+ self._length = length
+ self._secure = secure
+
+ @property
+ def length(self) -> int:
+ return self._length
+
+ @property
+ def secure(self) -> bool:
+ return self._secure
+
+ def generate(self):
+ random_func = secrets.choice if self._secure else random.choice
+ characters = string.ascii_letters + string.digits
+ random_string = "".join(random_func(characters) for _ in range(self._length))
+ return random_string
+
+
+UUID_VALUE_GENERATOR = UuidValueGenerator()
diff --git a/tools/cru-py/poetry.lock b/tools/cru-py/poetry.lock
new file mode 100644
index 0000000..305aaee
--- /dev/null
+++ b/tools/cru-py/poetry.lock
@@ -0,0 +1,80 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+
+[[package]]
+name = "mypy"
+version = "1.14.0"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"},
+ {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"},
+ {file = "mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e"},
+ {file = "mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3"},
+ {file = "mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44"},
+ {file = "mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a"},
+ {file = "mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc"},
+ {file = "mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015"},
+ {file = "mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb"},
+ {file = "mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc"},
+ {file = "mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd"},
+ {file = "mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1"},
+ {file = "mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63"},
+ {file = "mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d"},
+ {file = "mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba"},
+ {file = "mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741"},
+ {file = "mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7"},
+ {file = "mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8"},
+ {file = "mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc"},
+ {file = "mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f"},
+ {file = "mypy-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286"},
+ {file = "mypy-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad"},
+ {file = "mypy-1.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c"},
+ {file = "mypy-1.14.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01"},
+ {file = "mypy-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa"},
+ {file = "mypy-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e"},
+ {file = "mypy-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc"},
+ {file = "mypy-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b"},
+ {file = "mypy-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7"},
+ {file = "mypy-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f"},
+ {file = "mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab"},
+ {file = "mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6"},
+]
+
+[package.dependencies]
+mypy_extensions = ">=1.0.0"
+typing_extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+faster-cache = ["orjson"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.11"
+content-hash = "34a84c9f444021c048be3a70dbb3246bb73c4e7e8f0cc980b8050debcf21a6f9"
diff --git a/tools/cru-py/pyproject.toml b/tools/cru-py/pyproject.toml
new file mode 100644
index 0000000..e5e7f09
--- /dev/null
+++ b/tools/cru-py/pyproject.toml
@@ -0,0 +1,26 @@
+[project]
+name = "cru-py"
+version = "0.1.0"
+requires-python = ">=3.11"
+
+[tool.poetry]
+package-mode = false
+name = "cru"
+version = "0.1.0"
+description = ""
+authors = ["Yuqian Yang <crupest@crupest.life>"]
+license = "MIT"
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.11"
+
+[tool.poetry.group.dev.dependencies]
+mypy = "^1.13.0"
+
+[tool.ruff.lint]
+select = ["E", "F", "B"]
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/tools/cru-py/www-dev b/tools/cru-py/www-dev
new file mode 100644
index 0000000..f56d679
--- /dev/null
+++ b/tools/cru-py/www-dev
@@ -0,0 +1,8 @@
+#! /usr/bin/env sh
+
+set -e
+
+cd "$(dirname "$0")/../.."
+
+exec tmux new-session 'cd docker/crupest-nginx/sites/www && pnpm start' \; \
+ split-window -h 'cd docker/crupest-api/CrupestApi/CrupestApi && dotnet run --launch-profile dev'
diff --git a/tools/manage b/tools/manage
new file mode 100755
index 0000000..dc7f64b
--- /dev/null
+++ b/tools/manage
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+set -e
+
+python3.11 --version > /dev/null 2>&1 || (
+ echo Error: failed to run Python with python3.11 --version.
+ exit 1
+)
+
+script_dir=$(dirname "$0")
+project_dir=$(realpath "$script_dir/..")
+
+cd "$project_dir"
+
+export PYTHONPATH="$project_dir/tools/cru-py:$PYTHONPATH"
+python3.11 -m cru.service --project-dir "$project_dir" "$@"
diff --git a/tools/manage.cmd b/tools/manage.cmd
new file mode 100644
index 0000000..fce913d
--- /dev/null
+++ b/tools/manage.cmd
@@ -0,0 +1,15 @@
+@echo off
+
+set PYTHON=py -3
+%PYTHON% --version >NUL 2>&1 || (
+ echo Error: failed to run Python with py -3 --version.
+ exit 1
+)
+
+set TOOLS_DIR=%~dp0
+set PROJECT_DIR=%TOOLS_DIR%..
+
+cd /d "%PROJECT_DIR%"
+
+set PYTHONPATH=%PROJECT_DIR%\tools\cru-py;%PYTHONPATH%
+%PYTHON% -m cru.service --project-dir "%PROJECT_DIR%" %*
diff --git a/tools/scripts/neovide-listen b/tools/scripts/neovide-listen
new file mode 100755
index 0000000..2591842
--- /dev/null
+++ b/tools/scripts/neovide-listen
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+
+if [[ -z "$NVIM_SOCKET" ]]; then
+ NVIM_SOCKET="/tmp/nvimsocket"
+fi
+
+args=()
+MY_NEOVIM_PATH="$HOME/codes/neovim/build/bin/nvim"
+if [[ -e "$MY_NEOVIM_PATH" ]]; then
+ echo "Found my neovim at $MY_NEOVIM_PATH"
+ export VIMRUNTIME="$HOME/codes/neovim/runtime"
+ args=("${args[@]}" "--neovim-bin" "$MY_NEOVIM_PATH")
+fi
+
+listen_added=0
+for arg in "$@"; do
+ args=("${args[@]}" "$arg")
+ if [ "$arg" = '--' ]; then
+ args=("${args[@]}" "--listen" "$NVIM_SOCKET")
+ listen_added=1
+ fi
+done
+
+if [[ $listen_added = 0 ]]; then
+ args=("${args[@]}" "--" "--listen" "$NVIM_SOCKET")
+fi
+
+NEOVIDE_BIN=neovide
+MY_NEOVIDE_PATH="$HOME/codes/neovide/target/release/neovide"
+if [ -e "$MY_NEOVIDE_PATH" ]; then
+ echo "Found my neovide at $MY_NEOVIDE_PATH"
+ NEOVIDE_BIN="$MY_NEOVIDE_PATH"
+fi
+
+if which nvr > /dev/null; then
+ echo "Detected nvr, set git editor env"
+ export GIT_EDITOR='nvr -cc split --remote-wait'
+fi
+
+args=("$NEOVIDE_BIN" "${args[@]}")
+echo "Command is ${args[@]}"
+exec "${args[@]}"
+
diff --git a/tools/scripts/neovide-listen.ps1 b/tools/scripts/neovide-listen.ps1
new file mode 100644
index 0000000..e84f3a2
--- /dev/null
+++ b/tools/scripts/neovide-listen.ps1
@@ -0,0 +1,38 @@
+$env:NVIM_LISTEN_ADDRESS ??= "\\.\pipe\nvimsocket"
+
+$neovide_args = @()
+
+$MY_NEOVIM_PATH="$HOME/codes/neovim/build/bin/nvim.exe"
+if (Get-Item $MY_NEOVIM_PATH -ErrorAction Ignore) {
+ Write-Output "Found my neovim at $MY_NEOVIM_PATH."
+ $env:VIMRUNTIME="$HOME/codes/neovim/runtime"
+ $neovide_args += "--neovim-bin", "$MY_NEOVIM_PATH"
+}
+
+$listen_added = $false
+foreach ($arg in $args) {
+ $neovide_args += $arg
+ if ( $arg -eq '--') {
+ $neovide_args += "--listen", $env:NVIM_LISTEN_ADDRESS
+ $listen_added=$true
+ }
+}
+
+if (-not $listen_added) {
+ $neovide_args += "--", "--listen", $env:NVIM_LISTEN_ADDRESS
+}
+
+$neovide_bin = "neovide"
+$my_neovide_path = "$HOME/codes/neovide/target/release/neovide.exe"
+if (Get-Item $my_neovide_path -ErrorAction Ignore) {
+ Write-Output "Found my neovide at $my_neovide_path."
+ $neovide_bin = "$my_neovide_path"
+}
+
+if (Get-Command nvr -ErrorAction Ignore) {
+ Write-Output "Detected nvr, set git editor env."
+ $env:GIT_EDITOR = "nvr -cc split --remote-wait"
+}
+
+Write-Output "Command is $($neovide_args -join ' ')."
+Start-Process $neovide_bin -ArgumentList $neovide_args -Wait
diff --git a/tools/update-blog b/tools/update-blog
new file mode 100755
index 0000000..5314f47
--- /dev/null
+++ b/tools/update-blog
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -e
+
+exec docker exec -it blog /scripts/update.bash
diff --git a/tools/utility/rename-tree.py b/tools/utility/rename-tree.py
new file mode 100755
index 0000000..c177eb6
--- /dev/null
+++ b/tools/utility/rename-tree.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+
+import argparse
+import os
+import os.path
+import re
+
+parser = argparse.ArgumentParser(
+ prog='rename-tree',
+ description='Recursively rename directories and files')
+
+parser.add_argument('old')
+parser.add_argument('new')
+parser.add_argument('dirs', nargs="+")
+
+args = parser.parse_args()
+
+old_regex = re.compile(args.old)
+new = args.new
+
+def rename(path, isdir):
+ dirname = os.path.dirname(path)
+ filename = os.path.basename(path)
+ new_filename = re.sub(old_regex, new, filename)
+ dir_str = "/" if isdir else ""
+ if new_filename != filename:
+ os.rename(path, os.path.join(dirname, new_filename))
+ print(f"{path}{dir_str} -> {new_filename}{dir_str}")
+
+for i, d in enumerate(args.dirs):
+ print(f"[{i + 1}/{len(args.dirs)}] Run for {d}:")
+ for dirpath, dirnames, filenames in os.walk(d, topdown=False):
+ for filename in filenames:
+ rename(os.path.join(dirpath, filename), False)
+ rename(dirpath, True)
+
+print("Done!")