Regression aus dem vorigen Commit - /api/license/v1/validate lieferte die Antwort im neuen status/error-Umschlag. Der Vertrag dieses Endpunkts ist aber bereits ausgerollt: das Feld "status" auf oberster Ebene trägt den Lizenzzustand (valid, revoked, expired ...). LicenseClient las dadurch "success" statt "valid" — jeder ausgelieferte Client hätte seine Lizenz für ungültig gehalten. Die Lizenz-Endpunkte antworten jetzt wieder ohne Umschlag (Http::raw). Gefunden durch Ausführen der projekteigenen Test-Suite gegen den Server. Packager - FTP-Zugangsdaten standen als Standardwerte im Quelltext und zusätzlich in packager.config.json und in der Integrationsanleitung. Alle drei Fundstellen bereinigt; die Konfigurationsdatei ist nicht mehr versioniert. Zugangsdaten kommen aus Datei, Umgebungsvariablen oder CLI-Argument, sonst bricht das Programm mit einer klaren Meldung ab. - Das Veröffentlichen sendet jetzt ein Token (updateservice:publish) und nutzt den Endpunkt /api/updateservice/v1/publish. - Fehler wurden von einem leeren catch verschluckt, und ohne Erfolgsfall wurde gar nichts ausgegeben. Das Werkzeug meldete am Ende immer Erfolg und lieferte Rückgabewert 0, selbst wenn FTP-Upload und API-Aufruf fehlgeschlagen waren. Jetzt ehrliche Meldungen und Rückgabewerte 0/1/2. - packager.config.json wurde vom csproj nie ins Ausgabeverzeichnis kopiert, weshalb sie dort nie gefunden wurde und stets die hartkodierten Werte griffen. UpdateClient - IsVersionNewer entfernte die Vorabkennung, aber kein führendes "v". Damit scheiterte Version.TryParse bei "v1.4.2" und es wurde auf einen alphabetischen Vergleich zurückgefallen, in dem "v1.9.0" als neuer gilt als "v1.10.0" — derselbe Fehler wie zuvor serverseitig im SQL. Ersetzt durch einen vollständigen semantischen Vergleich, verifiziert mit 16 Testfällen. - Der Rückfall auf die API lag in einem catch-Block, aber GetAsync wirft bei einem 404 keine Exception. Fehlte die statische latest.json, brach die Prüfung ab, statt die API zu befragen. Dokumentation - BUGTRACKER_INTEGRATION_GUIDE.md beschrieb denselben Workflow ein zweites Mal und war bereits auseinandergelaufen: Aufrufe ohne Token, alte Pfade, weder Claim/Lease noch Idempotenz. Ersetzt durch einen Verweis auf das gepflegte Agenten-Handbuch samt Übersicht der Änderungen. - UPDATESERVICE_INTEGRATION_GUIDE.md um Token, Umgebungsvariablen und Rückgabewerte ergänzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
552 lines
23 KiB
C#
552 lines
23 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Formats.Tar;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using Deploymentcenter.Client.Models;
|
|
using FluentFTP;
|
|
|
|
namespace Deploymentcenter.Packager
|
|
{
|
|
/// <summary>
|
|
/// Konfiguration des Packagers.
|
|
///
|
|
/// Die Zugangsdaten standen zuvor als Standardwerte direkt im Quelltext und
|
|
/// lagen damit im Repository. Sie kommen jetzt ausschliesslich aus
|
|
/// packager.config.json (nicht versioniert) oder aus Umgebungsvariablen.
|
|
/// Fehlen sie, bricht das Programm mit einer klaren Meldung ab, statt sich
|
|
/// mit veralteten Werten zu verbinden.
|
|
/// </summary>
|
|
public class PackagerConfig
|
|
{
|
|
public string FtpHost { get; set; } = "";
|
|
public int FtpPort { get; set; } = 21;
|
|
public string FtpUser { get; set; } = "";
|
|
public string FtpPass { get; set; } = "";
|
|
public string FtpRemoteBaseDir { get; set; } = "/public_html/releases";
|
|
public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de";
|
|
|
|
/// <summary>
|
|
/// Token mit dem Recht updateservice:publish. Das Veroeffentlichen eines
|
|
/// Releases ist nicht mehr unauthentifiziert moeglich.
|
|
/// </summary>
|
|
public string ApiToken { get; set; } = "";
|
|
|
|
public List<string> ExcludePatterns { get; set; } = new List<string>
|
|
{
|
|
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*"
|
|
};
|
|
|
|
/// <summary>Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei.</summary>
|
|
public void ApplyEnvironmentOverrides()
|
|
{
|
|
FtpHost = Env("DC_FTP_HOST", FtpHost);
|
|
FtpUser = Env("DC_FTP_USER", FtpUser);
|
|
FtpPass = Env("DC_FTP_PASS", FtpPass);
|
|
ApiBaseUrl = Env("DC_API_URL", ApiBaseUrl);
|
|
ApiToken = Env("DC_TOKEN", ApiToken);
|
|
|
|
string port = Env("DC_FTP_PORT", "");
|
|
if (int.TryParse(port, out int parsedPort) && parsedPort > 0)
|
|
{
|
|
FtpPort = parsedPort;
|
|
}
|
|
}
|
|
|
|
private static string Env(string name, string fallback)
|
|
{
|
|
string? value = Environment.GetEnvironmentVariable(name);
|
|
return string.IsNullOrWhiteSpace(value) ? fallback : value;
|
|
}
|
|
}
|
|
|
|
class Program
|
|
{
|
|
static async Task<int> Main(string[] args)
|
|
{
|
|
Console.WriteLine("=================================================");
|
|
Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 ");
|
|
Console.WriteLine("=================================================");
|
|
|
|
string project = GetArg(args, "--project", "-p") ?? "myapp";
|
|
string version = GetArg(args, "--version", "-v") ?? "1.0.0";
|
|
string channel = GetArg(args, "--channel", "-c") ?? "prod";
|
|
string publishDir = GetArg(args, "--publish-dir", "-d") ?? Directory.GetCurrentDirectory();
|
|
string changelog = GetArg(args, "--changelog") ?? $"Release v{version}";
|
|
bool isCritical = HasFlag(args, "--critical");
|
|
string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json");
|
|
|
|
PackagerConfig config = LoadConfig(configFile);
|
|
config.ApplyEnvironmentOverrides();
|
|
|
|
// Reihenfolge: CLI-Argument, dann Umgebungsvariable, dann Datei.
|
|
string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost;
|
|
string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser;
|
|
string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass;
|
|
string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir;
|
|
string apiToken = GetArg(args, "--token") ?? config.ApiToken;
|
|
|
|
var missing = new List<string>();
|
|
if (string.IsNullOrWhiteSpace(ftpHost)) missing.Add("FTP-Host (--ftp-host / DC_FTP_HOST / ftpHost)");
|
|
if (string.IsNullOrWhiteSpace(ftpUser)) missing.Add("FTP-Benutzer (--ftp-user / DC_FTP_USER / ftpUser)");
|
|
if (string.IsNullOrWhiteSpace(ftpPass)) missing.Add("FTP-Passwort (--ftp-pass / DC_FTP_PASS / ftpPass)");
|
|
|
|
if (missing.Count > 0)
|
|
{
|
|
Console.ForegroundColor = ConsoleColor.Red;
|
|
Console.WriteLine("[FEHLER] Konfiguration unvollstaendig:");
|
|
foreach (var item in missing)
|
|
{
|
|
Console.WriteLine($" - {item}");
|
|
}
|
|
Console.ResetColor();
|
|
Console.WriteLine();
|
|
Console.WriteLine($"Vorlage kopieren: {Path.GetFileName(configFile)}.example -> {Path.GetFileName(configFile)}");
|
|
return 1;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(apiToken))
|
|
{
|
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
|
Console.WriteLine("[WARNUNG] Kein API-Token gesetzt (--token / DC_TOKEN / apiToken).");
|
|
Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter");
|
|
Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0");
|
|
Console.WriteLine(" ein Token mit dem Recht updateservice:publish.");
|
|
Console.ResetColor();
|
|
}
|
|
|
|
publishDir = Path.GetFullPath(publishDir);
|
|
if (!Directory.Exists(publishDir))
|
|
{
|
|
Console.ForegroundColor = ConsoleColor.Red;
|
|
Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}");
|
|
Console.ResetColor();
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine($"[INFO] Packaging Project : {project}");
|
|
Console.WriteLine($"[INFO] Version : {version}");
|
|
Console.WriteLine($"[INFO] Channel : {channel}");
|
|
Console.WriteLine($"[INFO] Publish Directory : {publishDir}");
|
|
|
|
// 1. Gather files and filter exclusions
|
|
var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories);
|
|
var filteredFiles = new List<string>();
|
|
|
|
foreach (var file in allFiles)
|
|
{
|
|
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
|
|
if (IsExcluded(relPath, config.ExcludePatterns))
|
|
{
|
|
Console.WriteLine($" [EXCLUDED] {relPath}");
|
|
continue;
|
|
}
|
|
filteredFiles.Add(file);
|
|
}
|
|
|
|
Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}");
|
|
|
|
// 2. Prepare staging directory
|
|
string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(outputTempDir);
|
|
|
|
string packageTarGzPath = Path.Combine(outputTempDir, "package.tar.gz");
|
|
string manifestJsonPath = Path.Combine(outputTempDir, "manifest.json");
|
|
string sha256FilePath = Path.Combine(outputTempDir, "package.tar.gz.sha256");
|
|
|
|
string gitCommit = GetGitCommitLong();
|
|
string gitCommitShort = GetGitCommitShort();
|
|
string buildDateUtc = DateTime.UtcNow.ToString("o");
|
|
|
|
// Build Manifest
|
|
var packageManifest = new PackageManifest
|
|
{
|
|
ProjectId = project,
|
|
Version = version,
|
|
Channel = channel,
|
|
BuildDate = buildDateUtc,
|
|
GitCommit = gitCommit,
|
|
GitCommitShort = gitCommitShort,
|
|
Changelog = changelog,
|
|
Files = new List<PackageFileEntry>()
|
|
};
|
|
|
|
foreach (var file in filteredFiles)
|
|
{
|
|
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
|
|
long size = new FileInfo(file).Length;
|
|
string hash = ComputeSha256(file);
|
|
packageManifest.Files.Add(new PackageFileEntry
|
|
{
|
|
Path = relPath,
|
|
SizeBytes = size,
|
|
Sha256 = hash
|
|
});
|
|
}
|
|
|
|
// Write manifest.json
|
|
string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(manifestJsonPath, manifestJson);
|
|
|
|
// 3. Create package.tar.gz
|
|
Console.WriteLine("[INFO] Creating package.tar.gz archive...");
|
|
string archiveStaging = Path.Combine(outputTempDir, "archive_root");
|
|
Directory.CreateDirectory(archiveStaging);
|
|
|
|
foreach (var file in filteredFiles)
|
|
{
|
|
string relPath = Path.GetRelativePath(publishDir, file);
|
|
string targetFile = Path.Combine(archiveStaging, relPath);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!);
|
|
File.Copy(file, targetFile, true);
|
|
}
|
|
|
|
// Also place manifest.json inside archive root
|
|
File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true);
|
|
|
|
// Compress to tar.gz using System.Formats.Tar + GZipStream
|
|
using (var fs = File.Create(packageTarGzPath))
|
|
using (var gz = new GZipStream(fs, CompressionLevel.Optimal))
|
|
{
|
|
TarFile.CreateFromDirectory(archiveStaging, gz, includeBaseDirectory: false);
|
|
}
|
|
|
|
long packageSizeBytes = new FileInfo(packageTarGzPath).Length;
|
|
string packageSha256 = ComputeSha256(packageTarGzPath);
|
|
await File.WriteAllTextAsync(sha256FilePath, packageSha256);
|
|
|
|
Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)");
|
|
Console.WriteLine($"[INFO] Package SHA256: {packageSha256}");
|
|
|
|
// 4. FTP Upload to LEMP Release Server
|
|
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}";
|
|
string remoteVersionPath = $"{remoteChannelPath}/{version}";
|
|
|
|
Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})...");
|
|
|
|
bool ftpSucceeded = false;
|
|
|
|
try
|
|
{
|
|
using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort);
|
|
await ftp.Connect();
|
|
|
|
await ftp.CreateDirectory(remoteVersionPath);
|
|
|
|
// Upload package.tar.gz, package.tar.gz.sha256, and manifest.json
|
|
await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite);
|
|
await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite);
|
|
await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite);
|
|
|
|
Console.WriteLine("[SUCCESS] Files uploaded to version directory!");
|
|
|
|
// 5. Update remote channel latest.json
|
|
string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json";
|
|
ReleaseManifest channelManifest = new ReleaseManifest
|
|
{
|
|
ProjectId = project,
|
|
Channel = channel,
|
|
Versions = new List<VersionInfo>()
|
|
};
|
|
|
|
// Read existing latest.json if present on FTP
|
|
if (await ftp.FileExists(remoteLatestJsonPath))
|
|
{
|
|
string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json");
|
|
var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
|
|
if (status == FtpStatus.Success && File.Exists(tempLatestLocal))
|
|
{
|
|
try
|
|
{
|
|
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
|
|
var existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
|
|
if (existingManifest != null && existingManifest.Versions != null)
|
|
{
|
|
channelManifest.Versions = existingManifest.Versions;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
|
|
// Construct new version info
|
|
string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz";
|
|
|
|
var newVersionInfo = new VersionInfo
|
|
{
|
|
Version = version,
|
|
BuildDate = buildDateUtc,
|
|
GitCommit = gitCommit,
|
|
GitCommitShort = gitCommitShort,
|
|
PackageUrl = packagePublicUrl,
|
|
Sha256 = packageSha256,
|
|
SizeBytes = packageSizeBytes,
|
|
Changelog = changelog,
|
|
IsCritical = isCritical
|
|
};
|
|
|
|
// Remove duplicate version entry if re-publishing same version
|
|
channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase));
|
|
channelManifest.Versions.Insert(0, newVersionInfo);
|
|
|
|
// Keep last 15 releases
|
|
if (channelManifest.Versions.Count > 15)
|
|
{
|
|
channelManifest.Versions = channelManifest.Versions.Take(15).ToList();
|
|
}
|
|
|
|
channelManifest.Latest = channelManifest.Versions.FirstOrDefault();
|
|
|
|
string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true });
|
|
string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json");
|
|
await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson);
|
|
|
|
await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite);
|
|
Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!");
|
|
|
|
await ftp.Disconnect();
|
|
ftpSucceeded = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.ForegroundColor = ConsoleColor.Red;
|
|
Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}");
|
|
Console.WriteLine(" Das Paket wurde NICHT ausgeliefert.");
|
|
Console.ResetColor();
|
|
}
|
|
|
|
// 6. Deploymentcenter benachrichtigen
|
|
//
|
|
// Zuvor stand hier ein leeres catch, und ohne Erfolgsfall wurde gar
|
|
// nichts ausgegeben. Ein fehlgeschlagener Aufruf blieb damit
|
|
// unsichtbar, waehrend das Programm am Ende Erfolg meldete.
|
|
bool apiNotified = false;
|
|
string apiMessage = "uebersprungen (kein Token gesetzt)";
|
|
|
|
if (!string.IsNullOrWhiteSpace(apiToken))
|
|
{
|
|
try
|
|
{
|
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
|
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish";
|
|
|
|
var payload = new
|
|
{
|
|
product_slug = project,
|
|
version = version,
|
|
channel = channel,
|
|
release_notes = changelog,
|
|
download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz",
|
|
sha256_hash = packageSha256,
|
|
git_commit = gitCommitShort,
|
|
size_bytes = packageSizeBytes,
|
|
is_critical = isCritical
|
|
};
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
|
|
};
|
|
request.Headers.Add("Authorization", $"Bearer {apiToken}");
|
|
|
|
var response = await http.SendAsync(request);
|
|
string body = await response.Content.ReadAsStringAsync();
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
apiNotified = true;
|
|
apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen.";
|
|
|
|
string? autoResolved = ExtractJsonString(body, "auto_resolved");
|
|
if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0")
|
|
{
|
|
apiMessage += $" ({autoResolved} Bugtracker-Item(s) automatisch geschlossen)";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
apiMessage = $"HTTP {(int)response.StatusCode}: "
|
|
+ (ExtractJsonString(body, "message") ?? body.Trim());
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
apiMessage = $"Aufruf fehlgeschlagen: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
if (apiNotified)
|
|
{
|
|
Console.ForegroundColor = ConsoleColor.Green;
|
|
Console.WriteLine($"[SUCCESS] {apiMessage}");
|
|
}
|
|
else
|
|
{
|
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
|
Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}");
|
|
}
|
|
Console.ResetColor();
|
|
|
|
// Cleanup temp
|
|
try { Directory.Delete(outputTempDir, true); } catch { }
|
|
|
|
// Der Rueckgabewert bildet jetzt ab, was tatsaechlich passiert ist.
|
|
// Zuvor wurde immer 0 und "successfully published" gemeldet, selbst
|
|
// wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren.
|
|
bool fullySucceeded = ftpSucceeded && apiNotified;
|
|
|
|
Console.WriteLine();
|
|
Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow;
|
|
Console.WriteLine(fullySucceeded
|
|
? $"[FERTIG] Release {version} fuer {project} ({channel}) vollstaendig veroeffentlicht."
|
|
: $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}): "
|
|
+ $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, "
|
|
+ $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}.");
|
|
Console.ResetColor();
|
|
|
|
return fullySucceeded ? 0 : 2;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein
|
|
/// vollstaendiges Modell dafuer zu benoetigen.
|
|
/// </summary>
|
|
static string? ExtractJsonString(string json, string propertyName)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return FindProperty(doc.RootElement, propertyName);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static string? FindProperty(JsonElement element, string propertyName)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (element.TryGetProperty(propertyName, out var direct))
|
|
{
|
|
return direct.ValueKind == JsonValueKind.String
|
|
? direct.GetString()
|
|
: direct.ToString();
|
|
}
|
|
|
|
// Fehlerantworten verpacken die Nachricht in einem "error"-Objekt.
|
|
foreach (var child in element.EnumerateObject())
|
|
{
|
|
if (child.Value.ValueKind == JsonValueKind.Object)
|
|
{
|
|
string? nested = FindProperty(child.Value, propertyName);
|
|
if (nested != null)
|
|
{
|
|
return nested;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static PackagerConfig LoadConfig(string path)
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
try
|
|
{
|
|
string json = File.ReadAllText(path);
|
|
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json);
|
|
if (cfg != null) return cfg;
|
|
}
|
|
catch { }
|
|
}
|
|
return new PackagerConfig();
|
|
}
|
|
|
|
static bool IsExcluded(string relPath, List<string> patterns)
|
|
{
|
|
string fileName = Path.GetFileName(relPath);
|
|
foreach (var pattern in patterns)
|
|
{
|
|
if (pattern.StartsWith("*."))
|
|
{
|
|
string ext = pattern.Substring(1);
|
|
if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
|
|
}
|
|
else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static string ComputeSha256(string file)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
using var stream = File.OpenRead(file);
|
|
byte[] hash = sha256.ComputeHash(stream);
|
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
}
|
|
|
|
static string? GetArg(string[] args, string flagLong, string? flagShort = null)
|
|
{
|
|
for (int i = 0; i < args.Length - 1; i++)
|
|
{
|
|
if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) ||
|
|
(flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return args[i + 1];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static bool HasFlag(string[] args, string flag)
|
|
{
|
|
return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
static string GetGitCommitLong()
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo("git", "rev-parse HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
|
|
using var p = Process.Start(psi);
|
|
string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
|
|
p?.WaitForExit();
|
|
if (!string.IsNullOrEmpty(outStr)) return outStr;
|
|
}
|
|
catch { }
|
|
return "UNKNOWN_COMMIT";
|
|
}
|
|
|
|
static string GetGitCommitShort()
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo("git", "rev-parse --short HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
|
|
using var p = Process.Start(psi);
|
|
string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
|
|
p?.WaitForExit();
|
|
if (!string.IsNullOrEmpty(outStr)) return outStr;
|
|
}
|
|
catch { }
|
|
return "UNKNOWN";
|
|
}
|
|
}
|
|
}
|