feat: Improve error handling and logging in installer commands
This commit is contained in:
parent
8b3f9284df
commit
c56fc21915
@ -1,4 +1,7 @@
|
|||||||
ASPNETCORE_ENVIRONMENT=Development
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
# Reduce noisy SQL statement logs (EF Core):
|
||||||
|
Logging__LogLevel__Microsoft__EntityFrameworkCore=Warning
|
||||||
|
Logging__LogLevel__Microsoft__EntityFrameworkCore__Database__Command=Warning
|
||||||
ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres
|
ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres
|
||||||
ESP__Provider=ses
|
ESP__Provider=ses
|
||||||
Db__AutoMigrate=true
|
Db__AutoMigrate=true
|
||||||
|
|||||||
@ -58,3 +58,5 @@ mass_mail_engine/
|
|||||||
- SES/SNS 簽章完整驗證(目前 `Ses__SkipSignatureValidation=false` 僅檢查 header 存在)
|
- SES/SNS 簽章完整驗證(目前 `Ses__SkipSignatureValidation=false` 僅檢查 header 存在)
|
||||||
- 事件重試/DLQ 策略補強(目前主要依 SQS redrive policy)
|
- 事件重試/DLQ 策略補強(目前主要依 SQS redrive policy)
|
||||||
- recipient 狀態機擴充(delivery/open/click 的完整優先序與狀態轉換)
|
- recipient 狀態機擴充(delivery/open/click 的完整優先序與狀態轉換)
|
||||||
|
- 與 Member Center 對齊並同步可讀的 list 名稱(避免 auto-created `list-{uuid}` 成為最終顯示)
|
||||||
|
- SES 郵件 header 補 `List-ID`(優先使用同步後的 list 名稱),避免 Gmail 取消訂閱視窗顯示 `(Unknown)`
|
||||||
|
|||||||
@ -27,6 +27,8 @@
|
|||||||
- 使用 Installer 建立 webhook client(`id` 自動隨機產生):
|
- 使用 Installer 建立 webhook client(`id` 自動隨機產生):
|
||||||
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> [--tenant-name <name>] --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
|
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> [--tenant-name <name>] --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
|
||||||
- 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --tenant-name "Tenant A" --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write`
|
- 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --tenant-name "Tenant A" --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write`
|
||||||
|
- 若 `client_id` 已存在且屬於同一 tenant,Installer 會重用既有 webhook client(不重複建立),可直接搭配 `--upsert-member-center` 做同步
|
||||||
|
- 若 `client_id` 已存在但屬於不同 tenant,Installer 會中止並回報錯誤
|
||||||
- 若 tenant 不存在,Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found`
|
- 若 tenant 不存在,Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found`
|
||||||
- 建立成功後,Member Center webhook header `X-Client-Id` 請帶回傳的 `id`
|
- 建立成功後,Member Center webhook header `X-Client-Id` 請帶回傳的 `id`
|
||||||
- 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程):
|
- 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程):
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
}
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
},
|
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||||
"AllowedHosts": "*"
|
}
|
||||||
}
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
|
|||||||
@ -9,57 +9,78 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
var command = args.Length > 0 ? args[0] : "migrate";
|
var command = args.Length > 0 ? args[0] : "migrate";
|
||||||
|
var verboseErrors = args.Any(x => string.Equals(x, "--verbose-errors", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var startedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
if (command is "-h" or "--help" or "help")
|
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||||
{
|
{
|
||||||
Console.WriteLine("SendEngine Installer");
|
WriteError("Unhandled installer exception.");
|
||||||
Console.WriteLine("Usage:");
|
if (eventArgs.ExceptionObject is Exception ex)
|
||||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
|
{
|
||||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <uuid> [--tenant-name <string>]");
|
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> [--tenant-name <string>] --client-id <string> --name <string> --scopes <csv> [--upsert-member-center --mc-base-url <url> --mc-client-id <string> --mc-client-secret <string> [--mc-scope <scope>] [--mc-token-path <path>] [--mc-upsert-path <path>] [--mc-token-url <url>] [--mc-upsert-url <url>]]");
|
}
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
var configuration = new ConfigurationBuilder()
|
try
|
||||||
.AddEnvironmentVariables()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
|
||||||
services.AddInfrastructure(configuration);
|
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
|
||||||
|
|
||||||
if (command == "migrate")
|
|
||||||
{
|
{
|
||||||
using var scope = provider.CreateScope();
|
// Always print a startup line so container logs are never completely empty.
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
WriteError($"[installer] start command={command} utc={startedAt:O}");
|
||||||
db.Database.Migrate();
|
|
||||||
Console.WriteLine("Database migration completed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command == "add-webhook-client")
|
if (command is "-h" or "--help" or "help")
|
||||||
{
|
{
|
||||||
var options = ParseOptions(args);
|
Console.WriteLine("SendEngine Installer");
|
||||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
Console.WriteLine("Usage:");
|
||||||
options.TryGetValue("tenant-name", out var tenantName);
|
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
|
||||||
options.TryGetValue("client-id", out var clientId);
|
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <uuid> [--tenant-name <string>]");
|
||||||
options.TryGetValue("name", out var name);
|
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> [--tenant-name <string>] --client-id <string> --name <string> --scopes <csv> [--upsert-member-center --mc-base-url <url> --mc-client-id <string> --mc-client-secret <string> [--mc-scope <scope>] [--mc-token-path <path>] [--mc-upsert-path <path>] [--mc-token-url <url>] [--mc-upsert-url <url>]]");
|
||||||
options.TryGetValue("scopes", out var scopesRaw);
|
Console.WriteLine(" optional: --verbose-errors (print full exception stack traces to stderr)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddInfrastructure(configuration);
|
||||||
|
Verbose("infrastructure registered");
|
||||||
|
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
Verbose("service provider built");
|
||||||
|
|
||||||
|
if (command == "migrate")
|
||||||
|
{
|
||||||
|
Verbose("running migrate");
|
||||||
|
using var scope = provider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||||
|
db.Database.Migrate();
|
||||||
|
Console.WriteLine("Database migration completed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command == "add-webhook-client")
|
||||||
|
{
|
||||||
|
Verbose("running add-webhook-client");
|
||||||
|
var options = ParseOptions(args);
|
||||||
|
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||||
|
options.TryGetValue("tenant-name", out var tenantName);
|
||||||
|
options.TryGetValue("client-id", out var clientId);
|
||||||
|
options.TryGetValue("name", out var name);
|
||||||
|
options.TryGetValue("scopes", out var scopesRaw);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(tenantIdRaw) ||
|
if (string.IsNullOrWhiteSpace(tenantIdRaw) ||
|
||||||
string.IsNullOrWhiteSpace(clientId) ||
|
string.IsNullOrWhiteSpace(clientId) ||
|
||||||
string.IsNullOrWhiteSpace(name) ||
|
string.IsNullOrWhiteSpace(name) ||
|
||||||
string.IsNullOrWhiteSpace(scopesRaw))
|
string.IsNullOrWhiteSpace(scopesRaw))
|
||||||
{
|
{
|
||||||
Console.WriteLine("Missing required options.");
|
WriteError("Missing required options.");
|
||||||
Console.WriteLine("Required: --tenant-id, --client-id, --name, --scopes");
|
WriteError("Required: --tenant-id, --client-id, --name, --scopes");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
||||||
{
|
{
|
||||||
Console.WriteLine("Invalid --tenant-id, expected UUID.");
|
WriteError("Invalid --tenant-id, expected UUID.");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,12 +91,14 @@ if (command == "add-webhook-client")
|
|||||||
|
|
||||||
if (scopes.Length == 0)
|
if (scopes.Length == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Invalid --scopes, expected comma-separated values.");
|
WriteError("Invalid --scopes, expected comma-separated values.");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var scope = provider.CreateScope();
|
using var scope = provider.CreateScope();
|
||||||
|
Verbose("db scope created");
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||||
|
Verbose("db context resolved");
|
||||||
|
|
||||||
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
||||||
if (tenant is null)
|
if (tenant is null)
|
||||||
@ -91,28 +114,36 @@ if (command == "add-webhook-client")
|
|||||||
Console.WriteLine($"Tenant created. tenant_id={tenant.Id}");
|
Console.WriteLine($"Tenant created. tenant_id={tenant.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId);
|
var entity = await db.AuthClients.FirstOrDefaultAsync(x => x.ClientId == clientId);
|
||||||
if (exists)
|
if (entity is not null)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"client_id already exists: {clientId}");
|
if (entity.TenantId != tenantId)
|
||||||
Environment.Exit(1);
|
{
|
||||||
|
WriteError($"client_id already exists under different tenant. client_id={clientId} existing_tenant_id={entity.TenantId} requested_tenant_id={tenantId}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Webhook client already exists. Reusing existing record.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entity = new AuthClient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
ClientId = clientId,
|
||||||
|
Name = name,
|
||||||
|
Scopes = scopes,
|
||||||
|
Status = "active",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AuthClients.Add(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Verbose("auth client saved");
|
||||||
|
Console.WriteLine("Webhook client created.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var entity = new AuthClient
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
TenantId = tenantId,
|
|
||||||
ClientId = clientId,
|
|
||||||
Name = name,
|
|
||||||
Scopes = scopes,
|
|
||||||
Status = "active",
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
db.AuthClients.Add(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
Console.WriteLine("Webhook client created.");
|
|
||||||
Console.WriteLine($"id={entity.Id}");
|
Console.WriteLine($"id={entity.Id}");
|
||||||
Console.WriteLine($"tenant_id={entity.TenantId}");
|
Console.WriteLine($"tenant_id={entity.TenantId}");
|
||||||
Console.WriteLine($"client_id={entity.ClientId}");
|
Console.WriteLine($"client_id={entity.ClientId}");
|
||||||
@ -123,15 +154,15 @@ if (command == "add-webhook-client")
|
|||||||
var mcSettings = ResolveMemberCenterUpsertSettings(options);
|
var mcSettings = ResolveMemberCenterUpsertSettings(options);
|
||||||
if (mcSettings is null)
|
if (mcSettings is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Missing required Member Center options for --upsert-member-center.");
|
WriteError("Missing required Member Center options for --upsert-member-center.");
|
||||||
Console.WriteLine("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
|
WriteError("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = await ResolveMemberCenterTokenAsync(mcSettings);
|
var token = await ResolveMemberCenterTokenAsync(mcSettings, verboseErrors);
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
{
|
{
|
||||||
Console.WriteLine("Member Center upsert failed: unable to get access token.");
|
WriteError("Member Center upsert failed: unable to get access token.");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,11 +176,12 @@ if (command == "add-webhook-client")
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Verbose($"calling member center upsert url={mcSettings.UpsertWebhookClientUrl}");
|
||||||
var response = await client.PostAsJsonAsync(mcSettings.UpsertWebhookClientUrl, upsertPayload);
|
var response = await client.PostAsJsonAsync(mcSettings.UpsertWebhookClientUrl, upsertPayload);
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
WriteError($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,34 +191,38 @@ if (command == "add-webhook-client")
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Member Center upsert request failed: {ex.Message}");
|
WriteError("Member Center upsert request failed.");
|
||||||
|
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command == "ensure-tenant")
|
if (command == "ensure-tenant")
|
||||||
{
|
{
|
||||||
var options = ParseOptions(args);
|
Verbose("running ensure-tenant");
|
||||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
var options = ParseOptions(args);
|
||||||
options.TryGetValue("tenant-name", out var tenantName);
|
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||||
|
options.TryGetValue("tenant-name", out var tenantName);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(tenantIdRaw))
|
if (string.IsNullOrWhiteSpace(tenantIdRaw))
|
||||||
{
|
{
|
||||||
Console.WriteLine("Missing required option: --tenant-id");
|
WriteError("Missing required option: --tenant-id");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
||||||
{
|
{
|
||||||
Console.WriteLine("Invalid --tenant-id, expected UUID.");
|
WriteError("Invalid --tenant-id, expected UUID.");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var scope = provider.CreateScope();
|
using var scope = provider.CreateScope();
|
||||||
|
Verbose("db scope created");
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||||
|
Verbose("db context resolved");
|
||||||
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
||||||
if (tenant is null)
|
if (tenant is null)
|
||||||
{
|
{
|
||||||
@ -205,10 +241,17 @@ if (command == "ensure-tenant")
|
|||||||
Console.WriteLine($"Tenant exists. tenant_id={tenant.Id}");
|
Console.WriteLine($"Tenant exists. tenant_id={tenant.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Unknown command: {command}");
|
Console.WriteLine($"Unknown command: {command}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WriteError("Installer failed with unhandled error.");
|
||||||
|
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
static Dictionary<string, string> ParseOptions(string[] args)
|
static Dictionary<string, string> ParseOptions(string[] args)
|
||||||
{
|
{
|
||||||
@ -262,7 +305,7 @@ static MemberCenterUpsertSettings? ResolveMemberCenterUpsertSettings(IReadOnlyDi
|
|||||||
return new MemberCenterUpsertSettings(tokenUrl, upsertUrl, clientId, clientSecret, scope);
|
return new MemberCenterUpsertSettings(tokenUrl, upsertUrl, clientId, clientSecret, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings)
|
static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings, bool verboseErrors)
|
||||||
{
|
{
|
||||||
using var client = new HttpClient();
|
using var client = new HttpClient();
|
||||||
var form = new Dictionary<string, string>
|
var form = new Dictionary<string, string>
|
||||||
@ -282,7 +325,7 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
|
|||||||
var body = await response.Content.ReadAsStringAsync();
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
WriteError($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,7 +341,8 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Member Center token request error: {ex.Message}");
|
WriteError("Member Center token request error.");
|
||||||
|
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -319,6 +363,19 @@ static string Truncate(string? input, int maxLen)
|
|||||||
return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)";
|
return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void WriteError(string message)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Verbose(string message)
|
||||||
|
{
|
||||||
|
if (verboseErrors)
|
||||||
|
{
|
||||||
|
WriteError($"[installer][verbose] {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sealed record MemberCenterUpsertSettings(
|
sealed record MemberCenterUpsertSettings(
|
||||||
string TokenUrl,
|
string TokenUrl,
|
||||||
string UpsertWebhookClientUrl,
|
string UpsertWebhookClientUrl,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user