- Installing the deb or the rpm now also adds the Arasaka package repository, packages.arasaka.software, so later versions of “me-web?” arrive through apt, dnf, zypper or your software centre, like any other program. - The rpm is now signed. On openSUSE, import the key once with sudo rpm --import https://packages.arasaka.software/arasaka.asc - Removing the package keeps the repository until no Arasaka package is left (purge on Debian and Ubuntu); a repository file you edited by hand is never touched. - The download and privacy pages say so; the repository server keeps no access log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
423 lines
19 KiB
C#
423 lines
19 KiB
C#
using System.Diagnostics;
|
|
using System.Formats.Tar;
|
|
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml.Linq;
|
|
|
|
const string KeystoreVariable = "MEWEB_KEYSTORE";
|
|
const string KeystorePasswordVariable = "MEWEB_KEYSTORE_PASS";
|
|
const string KeyAlias = "meweb";
|
|
string[] targetNames = ["linux", "windows", "macos", "android", "server"];
|
|
const UnixFileMode ExecutableMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead
|
|
| UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
|
const UnixFileMode RegularMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
|
|
|
var root = FindRoot();
|
|
var version = XDocument.Load(Path.Combine(root, "Directory.Build.props")).Descendants("Version").FirstOrDefault()?.Value.Trim();
|
|
if (version is null || !Regex.IsMatch(version, @"^\d+\.\d{1,2}\.\d{1,2}$"))
|
|
{
|
|
Console.Error.WriteLine($"invalid Version in Directory.Build.props: '{version}' (expected x.y.z)");
|
|
return 2;
|
|
}
|
|
var words = args.Where((a, i) => !a.StartsWith("--", StringComparison.Ordinal) && (i == 0 || !args[i - 1].StartsWith("--", StringComparison.Ordinal)))
|
|
.Select(a => a.ToLowerInvariant()).ToList();
|
|
if (words.Count == 0 || words.Any(w => w != "all" && w != "notes" && !targetNames.Contains(w)))
|
|
{
|
|
Console.Error.WriteLine("usage: tools/release.sh <all|linux|windows|macos|android|server|notes>... [--commit <sha>]");
|
|
return 2;
|
|
}
|
|
var targets = words.Contains("all") ? targetNames.ToList() : targetNames.Where(words.Contains).ToList();
|
|
var commit = Option("--commit") ?? "dev";
|
|
var builtAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
|
var output = Path.Combine(root, "releases", version);
|
|
var desktop = Path.Combine(root, "src", "MeWeb.Desktop", "MeWeb.Desktop.csproj");
|
|
var android = Path.Combine(root, "src", "MeWeb.Android", "MeWeb.Android.csproj");
|
|
var server = Path.Combine(root, "src", "MeWeb.Server", "MeWeb.Server.csproj");
|
|
var branding = Path.Combine(root, "branding");
|
|
Directory.CreateDirectory(output);
|
|
Console.WriteLine($"“me-web?” {version} ({commit}): {string.Join(", ", targets)} -> {output}");
|
|
if (targets.Count == 0)
|
|
{
|
|
WriteNotes();
|
|
Console.WriteLine(File.ReadAllText(Path.Combine(root, "artifacts", "release-notes.md")));
|
|
return 0;
|
|
}
|
|
|
|
var failed = false;
|
|
if (targets.Any(t => t != "server"))
|
|
{
|
|
Console.WriteLine("== knowledge");
|
|
RunTool("knowledge.cs");
|
|
}
|
|
foreach (var target in targets)
|
|
{
|
|
try
|
|
{
|
|
Console.WriteLine($"== {target}");
|
|
var detail = target switch
|
|
{
|
|
"linux" => Linux(),
|
|
"windows" => Windows(),
|
|
"macos" => MacOs(),
|
|
"android" => Android(),
|
|
_ => Server(),
|
|
};
|
|
Console.WriteLine($"ok {target}: {detail}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failed = true;
|
|
Console.Error.WriteLine($"FAIL {target}: {ex.Message}");
|
|
}
|
|
}
|
|
WriteSums();
|
|
WriteNotes();
|
|
return failed ? 1 : 0;
|
|
|
|
void WriteNotes()
|
|
{
|
|
var source = Path.Combine(root, "artifacts", "knowledge", "SOURCE.txt");
|
|
var lines = Changes();
|
|
lines.AddRange(
|
|
[
|
|
$"“me-web?” {version}, commit {commit}, built {builtAt}.",
|
|
"",
|
|
"Built with these open network datasets:",
|
|
"",
|
|
]);
|
|
lines.AddRange(File.Exists(source) ? File.ReadAllLines(source).Select(l => l.Length > 0 ? $" {l}" : "") : [" (no network data in this build)"]);
|
|
lines.AddRange(["", "SHA256SUMS.txt lists every file. The APK's signing certificate SHA-256 is 5f52cff4f52f4e0b6da6cd044bfceaf78d1d42db24f2470602db7f19cbfdf846."]);
|
|
Directory.CreateDirectory(Path.Combine(root, "artifacts"));
|
|
File.WriteAllLines(Path.Combine(root, "artifacts", "release-notes.md"), lines);
|
|
}
|
|
|
|
List<string> Changes()
|
|
{
|
|
var previous = Capture("git", "-C", root, "describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^").Trim();
|
|
var log = Capture("git", "-C", root, "log", "--no-merges", "--reverse", "--format=%x1e%s%x1f%b", previous.Length > 0 ? $"{previous}..HEAD" : "HEAD");
|
|
var lines = new List<string>();
|
|
foreach (var entry in log.Split('\x1e', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var parts = entry.Split('\x1f');
|
|
var subject = Regex.Replace(parts[0].Trim(), @"^[""“]?me-web\?[""”]? [\d.]+:\s*", "");
|
|
if (subject.Length == 0) continue;
|
|
var body = (parts.Length > 1 ? parts[1] : "").Split('\n').Select(l => l.TrimEnd())
|
|
.Where(l => !Regex.IsMatch(l, @"^[A-Za-z-]+-(By|Session|Off-By):", RegexOptions.IgnoreCase))
|
|
.Where(l => !l.Contains("claude", StringComparison.OrdinalIgnoreCase))
|
|
.SkipWhile(l => l.Length == 0).Reverse().SkipWhile(l => l.Length == 0).Reverse().ToList();
|
|
lines.AddRange([$"### {char.ToUpperInvariant(subject[0])}{subject[1..]}", ""]);
|
|
if (body.Count > 0) lines.AddRange([.. body, ""]);
|
|
}
|
|
return lines.Count == 0 ? [] : [$"## What's new in {version}", "", .. lines];
|
|
}
|
|
|
|
string[] Stamp() => [$"-p:Version={version}", $"-p:BuildCommit={commit}", $"-p:BuildTimeUtc={builtAt}"];
|
|
|
|
string Linux()
|
|
{
|
|
var stage = Reset(Path.Combine(root, "dist", "linux"));
|
|
var installed = Path.Combine(stage, "app");
|
|
Dotnet(["publish", desktop, "-c", "Release", "-r", "linux-x64", "--self-contained", "true", "-p:DebugType=none", "-o", installed, .. Stamp()]);
|
|
foreach (var required in new[] { "meweb", "libSkiaSharp.so", "libHarfBuzzSharp.so" })
|
|
if (!File.Exists(Path.Combine(installed, required))) throw new InvalidOperationException($"{required} missing from the Linux build");
|
|
foreach (var junk in Directory.GetFiles(installed, "*.pdb").Append(Path.Combine(installed, "libcoreclrtraceptprovider.so")))
|
|
if (File.Exists(junk)) File.Delete(junk);
|
|
NormaliseModes(installed, ["meweb", "createdump"]);
|
|
File.WriteAllText(Path.Combine(stage, "software.arasaka.meweb.desktop"), """
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=“me-web?”
|
|
Comment=Can you run your own server from here?
|
|
Comment[it]=Si può ospitare un server da questa connessione?
|
|
Exec=meweb
|
|
TryExec=meweb
|
|
Icon=software.arasaka.meweb
|
|
Terminal=false
|
|
Categories=Network;Utility;
|
|
StartupWMClass=meweb
|
|
|
|
""");
|
|
var caches = """
|
|
#!/bin/sh
|
|
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
|
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q -f -t /usr/share/icons/hicolor || true; fi
|
|
|
|
""";
|
|
var kit = File.ReadAllText(Path.Combine(root, "packaging", "arasaka-repository.sh"));
|
|
File.WriteAllText(Path.Combine(stage, "postinstall.sh"), $"{caches}{kit}arasaka_repository_configure \"$@\"\nexit 0\n");
|
|
File.WriteAllText(Path.Combine(stage, "postremove.sh"), $"{caches}{kit}arasaka_repository_cleanup \"$@\"\nexit 0\n");
|
|
File.WriteAllText(Path.Combine(stage, "arasaka-repository"), "");
|
|
var png = Path.Combine(branding, "meweb-512.png");
|
|
var svg = Path.Combine(branding, "meweb.svg");
|
|
var yaml = Path.Combine(stage, "nfpm.yaml");
|
|
File.WriteAllText(yaml, $"""
|
|
name: meweb
|
|
arch: amd64
|
|
platform: linux
|
|
version: {version}
|
|
release: "1"
|
|
section: net
|
|
priority: optional
|
|
maintainer: Arasaka di Hvozdikov Yevheniy <https://arasaka.software/contact>
|
|
vendor: Arasaka di Hvozdikov Yevheniy
|
|
homepage: https://git.thepra.dev/Arasaka/MeWeb
|
|
license: AGPL-3.0-only
|
|
description: |
|
|
Can you run your own server from here? “me-web?” checks your connection and device and
|
|
explains, in plain words, what you could host at home and what stands in the way.
|
|
contents:
|
|
- src: {installed}
|
|
dst: /opt/meweb
|
|
type: tree
|
|
- src: /opt/meweb/meweb
|
|
dst: /usr/bin/meweb
|
|
type: symlink
|
|
- src: {stage}/software.arasaka.meweb.desktop
|
|
dst: /usr/share/applications/software.arasaka.meweb.desktop
|
|
file_info:
|
|
mode: 0644
|
|
- src: {png}
|
|
dst: /usr/share/icons/hicolor/512x512/apps/software.arasaka.meweb.png
|
|
file_info:
|
|
mode: 0644
|
|
- src: {svg}
|
|
dst: /usr/share/icons/hicolor/scalable/apps/software.arasaka.meweb.svg
|
|
file_info:
|
|
mode: 0644
|
|
- src: {stage}/arasaka-repository
|
|
dst: /usr/share/arasaka-repository/meweb
|
|
file_info:
|
|
mode: 0644
|
|
scripts:
|
|
postinstall: {stage}/postinstall.sh
|
|
postremove: {stage}/postremove.sh
|
|
overrides:
|
|
deb:
|
|
depends:
|
|
- libc6 (>= 2.28)
|
|
- libgcc-s1
|
|
- libstdc++6
|
|
- libfontconfig1
|
|
- libx11-6
|
|
- libice6
|
|
- libsm6
|
|
- libssl3t64 | libssl3
|
|
- libicu78 | libicu76 | libicu74 | libicu72 | libicu70
|
|
- xdg-utils
|
|
recommends:
|
|
- fonts-noto-core
|
|
- fonts-noto-cjk
|
|
rpm:
|
|
depends:
|
|
- libc.so.6(GLIBC_2.28)(64bit)
|
|
- libstdc++.so.6(GLIBCXX_3.4.22)(64bit)
|
|
- libgcc_s.so.1()(64bit)
|
|
- libfontconfig.so.1()(64bit)
|
|
- libX11.so.6()(64bit)
|
|
- libICE.so.6()(64bit)
|
|
- libSM.so.6()(64bit)
|
|
- libssl.so.3()(64bit)
|
|
- libcrypto.so.3()(64bit)
|
|
- libicu
|
|
- /usr/bin/xdg-open
|
|
recommends:
|
|
- google-noto-sans-fonts
|
|
- google-noto-sans-cjk-fonts
|
|
rpm:
|
|
summary: Can you run your own server from here?
|
|
compression: zstd
|
|
deb:
|
|
compression: zstd
|
|
|
|
""");
|
|
var nfpm = Path.Combine(root, "tools", "packaging", "linux-x64", "nfpm");
|
|
if (!File.Exists(nfpm)) RunTool("setup.cs", "--group", "packaging");
|
|
var names = new List<string>();
|
|
foreach (var packager in new[] { "deb", "rpm" })
|
|
{
|
|
var name = $"MeWeb-{version}-linux-x64.{packager}";
|
|
Run(nfpm, "package", "--config", yaml, "--packager", packager, "--target", Path.Combine(output, name));
|
|
names.Add($"{name} ({Size(Path.Combine(output, name))})");
|
|
}
|
|
return string.Join(", ", names);
|
|
}
|
|
|
|
string Windows()
|
|
{
|
|
var stage = Reset(Path.Combine(root, "dist", "windows"));
|
|
Dotnet(["publish", desktop, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "-p:PublishSingleFile=true",
|
|
"-p:IncludeNativeLibrariesForSelfExtract=true", "-p:EnableCompressionInSingleFile=true", "-p:DebugType=none", "-o", stage, .. Stamp()]);
|
|
var exe = Path.Combine(stage, "meweb.exe");
|
|
if (!File.Exists(exe)) throw new InvalidOperationException("meweb.exe missing from the Windows build");
|
|
var name = $"MeWeb-{version}-windows-x64.exe";
|
|
File.Copy(exe, Path.Combine(output, name), overwrite: true);
|
|
return $"{name} ({Size(Path.Combine(output, name))})";
|
|
}
|
|
|
|
string MacOs()
|
|
{
|
|
var names = new List<string>();
|
|
foreach (var (rid, label) in new[] { ("osx-arm64", "arm64"), ("osx-x64", "x64") })
|
|
{
|
|
var stage = Reset(Path.Combine(root, "dist", "macos-" + label));
|
|
var bundle = Path.Combine(stage, "me-web.app");
|
|
var contents = Path.Combine(bundle, "Contents");
|
|
var binaries = Path.Combine(contents, "MacOS");
|
|
var resources = Path.Combine(contents, "Resources");
|
|
Directory.CreateDirectory(resources);
|
|
Dotnet(["publish", desktop, "-c", "Release", "-r", rid, "--self-contained", "true", "-p:DebugType=none", "-o", binaries, .. Stamp()]);
|
|
if (!File.Exists(Path.Combine(binaries, "meweb"))) throw new InvalidOperationException($"meweb missing from the {rid} build");
|
|
foreach (var pdb in Directory.GetFiles(binaries, "*.pdb")) File.Delete(pdb);
|
|
File.Copy(Path.Combine(branding, "meweb.icns"), Path.Combine(resources, "meweb.icns"), overwrite: true);
|
|
File.WriteAllText(Path.Combine(contents, "Info.plist"), $"""
|
|
<?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>CFBundleName</key><string>“me-web?”</string>
|
|
<key>CFBundleDisplayName</key><string>“me-web?”</string>
|
|
<key>CFBundleIdentifier</key><string>software.arasaka.meweb</string>
|
|
<key>CFBundleVersion</key><string>{version}</string>
|
|
<key>CFBundleShortVersionString</key><string>{version}</string>
|
|
<key>CFBundleExecutable</key><string>meweb</string>
|
|
<key>CFBundleIconFile</key><string>meweb.icns</string>
|
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
|
<key>NSHighResolutionCapable</key><true/>
|
|
<key>NSLocalNetworkUsageDescription</key><string>“me-web?” looks for your router to see whether it can open a port for a test.</string>
|
|
</dict>
|
|
</plist>
|
|
|
|
""");
|
|
NormaliseModes(binaries, ["meweb", "createdump"]);
|
|
var name = $"MeWeb-{version}-macos-{label}.zip";
|
|
var zip = Path.Combine(output, name);
|
|
if (File.Exists(zip)) File.Delete(zip);
|
|
RunIn(stage, "zip", "-q", "-r", "-y", zip, "me-web.app");
|
|
names.Add($"{name} ({Size(zip)})");
|
|
}
|
|
return string.Join(", ", names);
|
|
}
|
|
|
|
string Android()
|
|
{
|
|
var keystore = Environment.GetEnvironmentVariable(KeystoreVariable);
|
|
if (string.IsNullOrEmpty(keystore) || !File.Exists(keystore) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable(KeystorePasswordVariable)))
|
|
throw new InvalidOperationException($"{KeystoreVariable} (a keystore file) and {KeystorePasswordVariable} must be set to sign the APK");
|
|
EnsureWorkload("android");
|
|
var jdk = Jdk();
|
|
var sdk = AndroidSdk(jdk);
|
|
var stage = Reset(Path.Combine(root, "dist", "android"));
|
|
Dotnet(["publish", android, "-c", "Release", "-f", "net10.0-android", "-o", stage, $"-p:JavaSdkDirectory={jdk}", $"-p:AndroidSdkDirectory={sdk}",
|
|
"-p:AndroidKeyStore=true", $"-p:AndroidSigningKeyStore={keystore}", $"-p:AndroidSigningKeyAlias={KeyAlias}",
|
|
$"-p:AndroidSigningStorePass=env:{KeystorePasswordVariable}", $"-p:AndroidSigningKeyPass=env:{KeystorePasswordVariable}", .. Stamp()]);
|
|
var apk = Directory.GetFiles(stage, "*-Signed.apk").SingleOrDefault() ?? throw new InvalidOperationException("signed APK not found");
|
|
var name = $"MeWeb-{version}-android.apk";
|
|
File.Copy(apk, Path.Combine(output, name), overwrite: true);
|
|
return $"{name} ({Size(Path.Combine(output, name))})";
|
|
}
|
|
|
|
string Server()
|
|
{
|
|
var stage = Reset(Path.Combine(root, "dist", "server"));
|
|
Dotnet(["publish", server, "-c", "Release", "-r", "linux-x64", "--self-contained", "true", "-p:DebugType=none", "-o", stage, .. Stamp()]);
|
|
if (!File.Exists(Path.Combine(stage, "MeWeb.Server"))) throw new InvalidOperationException("MeWeb.Server missing from the server build");
|
|
NormaliseModes(stage, ["MeWeb.Server", "createdump"]);
|
|
var name = $"MeWeb-Server-{version}-linux-x64.tar.gz";
|
|
var archive = Path.Combine(output, name);
|
|
using (var file = File.Create(archive))
|
|
using (var gzip = new GZipStream(file, CompressionLevel.Optimal))
|
|
TarFile.CreateFromDirectory(stage, gzip, includeBaseDirectory: false);
|
|
return $"{name} ({Size(archive)})";
|
|
}
|
|
|
|
string Jdk()
|
|
{
|
|
var candidate = Directory.Exists(Path.Combine(root, "tools", "jdk", "linux-x64"))
|
|
? Directory.GetDirectories(Path.Combine(root, "tools", "jdk", "linux-x64")).FirstOrDefault(d => File.Exists(Path.Combine(d, "bin", "javac")))
|
|
: null;
|
|
if (candidate is not null) return candidate;
|
|
RunTool("setup.cs", "--group", "jdk");
|
|
return Directory.GetDirectories(Path.Combine(root, "tools", "jdk", "linux-x64")).First(d => File.Exists(Path.Combine(d, "bin", "javac")));
|
|
}
|
|
|
|
string AndroidSdk(string jdk)
|
|
{
|
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
string[] candidates = [Environment.GetEnvironmentVariable("ANDROID_HOME"), Path.Combine(home, "Android", "Sdk"), Path.Combine(root, "tools", "android-sdk")];
|
|
var sdk = candidates.FirstOrDefault(c => !string.IsNullOrEmpty(c) && Directory.Exists(Path.Combine(c, "platforms", "android-36"))) ?? Path.Combine(root, "tools", "android-sdk");
|
|
if (!Directory.Exists(Path.Combine(sdk, "platforms", "android-36")) || !Directory.Exists(Path.Combine(sdk, "build-tools")))
|
|
Dotnet(["build", android, "-t:InstallAndroidDependencies", "-f", "net10.0-android", $"-p:AndroidSdkDirectory={sdk}", $"-p:JavaSdkDirectory={jdk}",
|
|
"-p:AcceptAndroidSDKLicenses=True"]);
|
|
return sdk;
|
|
}
|
|
|
|
void EnsureWorkload(string name)
|
|
{
|
|
var installed = Capture("dotnet", "workload", "list");
|
|
if (!installed.Split('\n').Any(l => l.Trim().StartsWith(name + " ", StringComparison.Ordinal))) Run("dotnet", "workload", "install", name);
|
|
}
|
|
|
|
void WriteSums()
|
|
{
|
|
var lines = Directory.GetFiles(output).Where(f => !f.EndsWith("SHA256SUMS.txt", StringComparison.Ordinal)).OrderBy(f => f, StringComparer.Ordinal)
|
|
.Select(f => $"{Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(f)))} {Path.GetFileName(f)}");
|
|
File.WriteAllLines(Path.Combine(output, "SHA256SUMS.txt"), lines);
|
|
}
|
|
|
|
void NormaliseModes(string folder, string[] executables)
|
|
{
|
|
if (OperatingSystem.IsWindows()) return;
|
|
foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories))
|
|
File.SetUnixFileMode(file, executables.Contains(Path.GetFileName(file)) ? ExecutableMode : RegularMode);
|
|
foreach (var dir in Directory.EnumerateDirectories(folder, "*", SearchOption.AllDirectories)) File.SetUnixFileMode(dir, ExecutableMode);
|
|
}
|
|
|
|
string Reset(string folder)
|
|
{
|
|
if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true);
|
|
Directory.CreateDirectory(folder);
|
|
return folder;
|
|
}
|
|
|
|
string Size(string file) => $"{new FileInfo(file).Length / 1024.0 / 1024.0:0.0} MB";
|
|
|
|
string Option(string key)
|
|
{
|
|
var i = Array.IndexOf(args, key);
|
|
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
|
}
|
|
|
|
void Dotnet(string[] arguments) => Run("dotnet", [.. arguments, "-nologo", "-nodeReuse:false"]);
|
|
|
|
void RunTool(string script, params string[] arguments) => RunIn(root, "dotnet", ["run", Path.Combine("tools", script), "--", .. arguments]);
|
|
|
|
void Run(string file, params string[] arguments) => RunIn(root, file, arguments);
|
|
|
|
void RunIn(string directory, string file, params string[] arguments)
|
|
{
|
|
var info = new ProcessStartInfo(file) { UseShellExecute = false, WorkingDirectory = directory };
|
|
foreach (var argument in arguments) info.ArgumentList.Add(argument);
|
|
using var process = Process.Start(info) ?? throw new InvalidOperationException($"cannot start {file}");
|
|
process.WaitForExit();
|
|
if (process.ExitCode != 0) throw new InvalidOperationException($"{file} {string.Join(' ', arguments.Take(2))} exited with code {process.ExitCode}");
|
|
}
|
|
|
|
string Capture(string file, params string[] arguments)
|
|
{
|
|
var info = new ProcessStartInfo(file) { UseShellExecute = false, RedirectStandardOutput = true };
|
|
foreach (var argument in arguments) info.ArgumentList.Add(argument);
|
|
using var process = Process.Start(info) ?? throw new InvalidOperationException($"cannot start {file}");
|
|
var text = process.StandardOutput.ReadToEnd();
|
|
process.WaitForExit();
|
|
return text;
|
|
}
|
|
|
|
static string FindRoot()
|
|
{
|
|
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent;
|
|
return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)");
|
|
}
|