From 210ad09b6dfccafe15c03e4a99175a4c29f0ee4a Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 24 Apr 2026 21:07:28 +0200 Subject: [PATCH 01/27] feat(tooling): migrate docs-builder and aspire from ConsoleAppFramework to Nullean.Argh 0.7.0 Replaces ConsoleAppFramework with Nullean.Argh.Hosting in docs-builder and Nullean.Argh (standalone) in the aspire AppHost. Key improvements: proper namespace hierarchy (assembler, codex, etc.), record binding via [AsParameters] for the shared ElasticsearchIndexOptions, ICommandMiddleware replacing filter chain, GlobalCliOptions with --log-level/--config-source/--skip-private-repositories as typed global flags, and docs-builder assemble as a hoisted root command. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 4 +- aspire/AppHost.cs | 274 ++++++++++-------- aspire/aspire.csproj | 7 +- .../AppDefaultsExtensions.cs | 23 +- .../GlobalCommandLine.cs | 75 ++--- .../docs-builder/Arguments/ExportOption.cs | 23 +- .../Arguments/ProductInfoParser.cs | 49 ++-- .../Commands/Assembler/AssemblerCommands.cs | 134 +++++---- .../Assembler/AssemblerIndexCommand.cs | 107 ++----- .../Assembler/AssemblerSitemapCommand.cs | 64 ++-- .../Commands/Assembler/BloomFilterCommands.cs | 25 +- .../Assembler/ConfigurationCommands.cs | 18 +- .../Assembler/ContentSourceCommands.cs | 26 +- .../Commands/Assembler/DeployCommands.cs | 45 ++- .../Commands/Assembler/NavigationCommands.cs | 24 +- .../docs-builder/Commands/ChangelogCommand.cs | 105 +++---- .../Commands/Codex/CodexCommands.cs | 108 ++++--- .../Commands/Codex/CodexIndexCommand.cs | 108 ++----- .../Codex/CodexUpdateRedirectsCommand.cs | 24 +- .../docs-builder/Commands/DiffCommands.cs | 20 +- .../docs-builder/Commands/FormatCommand.cs | 53 ---- .../Commands/InboundLinkCommands.cs | 38 ++- .../docs-builder/Commands/IndexCommand.cs | 108 ++----- .../Commands/IsolatedBuildCommand.cs | 48 +-- .../docs-builder/Commands/MoveCommand.cs | 64 +++- .../docs-builder/Commands/ServeCommand.cs | 31 +- .../docs-builder/DocumentationTooling.cs | 3 - .../docs-builder/Filters/InfoLoggerFilter.cs | 36 --- .../docs-builder/Filters/ReplaceLogFilter.cs | 31 -- .../docs-builder/Filters/StopwatchFilter.cs | 31 -- src/tooling/docs-builder/GlobalCliOptions.cs | 23 ++ .../CatchExceptionMiddleware.cs} | 22 +- .../CheckForUpdatesMiddleware.cs} | 60 ++-- .../Middleware/InfoLoggerMiddleware.cs | 28 ++ .../Middleware/StopwatchMiddleware.cs | 28 ++ src/tooling/docs-builder/Program.cs | 85 +++--- src/tooling/docs-builder/docs-builder.csproj | 5 +- 37 files changed, 871 insertions(+), 1086 deletions(-) delete mode 100644 src/tooling/docs-builder/Commands/FormatCommand.cs delete mode 100644 src/tooling/docs-builder/Filters/InfoLoggerFilter.cs delete mode 100644 src/tooling/docs-builder/Filters/ReplaceLogFilter.cs delete mode 100644 src/tooling/docs-builder/Filters/StopwatchFilter.cs create mode 100644 src/tooling/docs-builder/GlobalCliOptions.cs rename src/tooling/docs-builder/{Filters/CatchExceptionFilter.cs => Middleware/CatchExceptionMiddleware.cs} (52%) rename src/tooling/docs-builder/{Filters/CheckForUpdatesFilter.cs => Middleware/CheckForUpdatesMiddleware.cs} (51%) create mode 100644 src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs create mode 100644 src/tooling/docs-builder/Middleware/StopwatchMiddleware.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e8982479d7..2318f99a66 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,8 +69,8 @@ - - + + diff --git a/aspire/AppHost.cs b/aspire/AppHost.cs index b5dd60527a..6287569def 100644 --- a/aspire/AppHost.cs +++ b/aspire/AppHost.cs @@ -2,125 +2,173 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information - -using ConsoleAppFramework; using Elastic.Documentation; +using Nullean.Argh; using static Elastic.Documentation.Aspire.ResourceNames; -GlobalCli.Process(ref args, out _, out var globalArguments); +// Extract global doc-builder flags before argh routing so they can be forwarded +// to docs-builder sub-process invocations (--log-level, --config-source, etc.). +AspireHost.GlobalArguments = AspireHost.ExtractGlobalArgs(ref args); -await ConsoleApp.RunAsync(args, BuildAspireHost); -return; +var app = new ArghApp(); +app.MapRoot(AspireHost.Run); +return await app.RunAsync(args); -// ReSharper disable once RedundantLambdaParameterType -// ReSharper disable once VariableHidesOuterVariable -async Task BuildAspireHost(bool startElasticsearch, bool assumeCloned, bool assumeBuild, bool skipPrivateRepositories, Cancel ctx) -{ - var builder = DistributedApplication.CreateBuilder(args); - - var llmUrl = builder.AddParameter("LlmGatewayUrl", secret: true); - var llmServiceAccountPath = builder.AddParameter("LlmGatewayServiceAccountPath", secret: true); - - var elasticsearchUrl = builder.AddParameter("DocumentationElasticUrl", secret: true); - var elasticsearchApiKey = builder.AddParameter("DocumentationElasticApiKey", secret: true); - - var cloneAll = builder.AddProject(AssemblerClone); - string[] cloneArgs = assumeCloned ? ["--assume-cloned"] : []; - cloneAll = cloneAll.WithArgs(["assembler", "clone", .. globalArguments, .. cloneArgs]); - - var buildAll = builder.AddProject(AssemblerBuild); - string[] buildArgs = assumeBuild ? ["--assume-build"] : []; - buildAll = buildAll - .WithArgs(["assembler", "build", .. globalArguments, .. buildArgs]) - .WaitForCompletion(cloneAll) - .WithParentRelationship(cloneAll); - - var elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal) - .WithEnvironment("LICENSE", "trial"); - if (!startElasticsearch) - elasticsearchLocal = elasticsearchLocal.WithExplicitStart(); - - var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); - - var api = builder.AddProject(Api) - .WithArgs(globalArguments) - .WithEnvironment("ENVIRONMENT", "dev") - .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) - .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath); - - // ReSharper disable once RedundantAssignment - api = startElasticsearch - ? api - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) - .WaitFor(elasticsearchLocal) - .WithExplicitStart() - : api.WithReference(elasticsearchRemote) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) - .WithExplicitStart(); +// ── Aspire host command ─────────────────────────────────────────────────────────────────────────── - var mcp = builder.AddProject(RemoteMcp) - .WithArgs(globalArguments) - .WithEnvironment("ENVIRONMENT", "dev"); - - // ReSharper disable once RedundantAssignment - mcp = startElasticsearch - ? mcp - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) - .WaitFor(elasticsearchLocal) - .WithExplicitStart() - : mcp.WithReference(elasticsearchRemote) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) +internal static class AspireHost +{ + internal static string[] GlobalArguments = []; + + /// + /// Starts the Elastic documentation Aspire AppHost. + /// + /// Start a local Elasticsearch container + /// Skip cloning; assume repositories are already present on disk + /// Skip building; assume build output already exists + /// Skip cloning private repositories + [NoOptionsInjection] + internal static async Task Run( + bool startElasticsearch = false, + bool assumeCloned = false, + bool assumeBuild = false, + bool skipPrivateRepositories = false, + CancellationToken ct = default) + { + var builder = DistributedApplication.CreateBuilder(); + + var llmUrl = builder.AddParameter("LlmGatewayUrl", secret: true); + var llmServiceAccountPath = builder.AddParameter("LlmGatewayServiceAccountPath", secret: true); + + var elasticsearchUrl = builder.AddParameter("DocumentationElasticUrl", secret: true); + var elasticsearchApiKey = builder.AddParameter("DocumentationElasticApiKey", secret: true); + + var cloneAll = builder.AddProject(AssemblerClone); + string[] cloneArgs = assumeCloned ? ["--assume-cloned"] : []; + cloneAll = cloneAll.WithArgs(["assembler", "clone", .. GlobalArguments, .. cloneArgs]); + + var buildAll = builder.AddProject(AssemblerBuild); + string[] buildArgs = assumeBuild ? ["--assume-build"] : []; + buildAll = buildAll + .WithArgs(["assembler", "build", .. GlobalArguments, .. buildArgs]) + .WaitForCompletion(cloneAll) + .WithParentRelationship(cloneAll); + + var elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal) + .WithEnvironment("LICENSE", "trial"); + if (!startElasticsearch) + elasticsearchLocal = elasticsearchLocal.WithExplicitStart(); + + var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); + + var api = builder.AddProject(Api) + .WithArgs(GlobalArguments) + .WithEnvironment("ENVIRONMENT", "dev") + .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) + .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath); + + // ReSharper disable once RedundantAssignment + api = startElasticsearch + ? api + .WithReference(elasticsearchLocal) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal) + .WaitFor(elasticsearchLocal) + .WithExplicitStart() + : api.WithReference(elasticsearchRemote) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) + .WithExplicitStart(); + + var mcp = builder.AddProject(RemoteMcp) + .WithArgs(GlobalArguments) + .WithEnvironment("ENVIRONMENT", "dev"); + + // ReSharper disable once RedundantAssignment + mcp = startElasticsearch + ? mcp + .WithReference(elasticsearchLocal) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal) + .WaitFor(elasticsearchLocal) + .WithExplicitStart() + : mcp.WithReference(elasticsearchRemote) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) + .WithExplicitStart(); + + var indexElasticsearch = builder.AddProject(ElasticsearchIngest) + .WithArgs(["assembler", "index", .. GlobalArguments]) + .WaitForCompletion(cloneAll) .WithExplicitStart(); - var indexElasticsearch = builder.AddProject(ElasticsearchIngest) - .WithArgs(["assembler", "index", .. globalArguments]) - .WaitForCompletion(cloneAll) - .WithExplicitStart(); - - // ReSharper disable once RedundantAssignment - indexElasticsearch = startElasticsearch - ? indexElasticsearch - .WaitFor(elasticsearchLocal) - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) - : indexElasticsearch - .WithReference(elasticsearchRemote) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) - .WithParentRelationship(elasticsearchRemote); - - var serveStatic = builder.AddProject(AssemblerServe) - .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) - .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath) - .WithHttpEndpoint(port: 4000, isProxied: false) - .WithArgs(["assembler", "serve", .. globalArguments]) - .WithHttpHealthCheck("/", 200) - .WaitForCompletion(buildAll) - .WithParentRelationship(cloneAll); - - serveStatic = startElasticsearch - ? serveStatic - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - : serveStatic - .WithReference(elasticsearchRemote) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); - - - // ReSharper disable once RedundantAssignment - serveStatic = startElasticsearch ? serveStatic.WaitFor(elasticsearchLocal) : serveStatic.WaitFor(buildAll); - - await builder.Build().RunAsync(ctx); + // ReSharper disable once RedundantAssignment + indexElasticsearch = startElasticsearch + ? indexElasticsearch + .WaitFor(elasticsearchLocal) + .WithReference(elasticsearchLocal) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal) + : indexElasticsearch + .WithReference(elasticsearchRemote) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) + .WithParentRelationship(elasticsearchRemote); + + var serveStatic = builder.AddProject(AssemblerServe) + .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) + .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath) + .WithHttpEndpoint(port: 4000, isProxied: false) + .WithArgs(["assembler", "serve", .. GlobalArguments]) + .WithHttpHealthCheck("/", 200) + .WaitForCompletion(buildAll) + .WithParentRelationship(cloneAll); + + serveStatic = startElasticsearch + ? serveStatic + .WithReference(elasticsearchLocal) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) + : serveStatic + .WithReference(elasticsearchRemote) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); + + // ReSharper disable once RedundantAssignment + serveStatic = startElasticsearch ? serveStatic.WaitFor(elasticsearchLocal) : serveStatic.WaitFor(buildAll); + + await builder.Build().RunAsync(ct); + } + + /// + /// Extracts global doc-builder flags (--log-level, --config-source, --skip-private-repositories) + /// from in-place, returning them for forwarding to docs-builder sub-processes. + /// + internal static string[] ExtractGlobalArgs(ref string[] args) + { + var global = new List(); + var remaining = new List(); + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--log-level" && i + 1 < args.Length) + { + global.Add("--log-level"); + global.Add(args[++i]); + } + else if (args[i] is "--config-source" or "--configuration-source" or "-c" && i + 1 < args.Length) + { + global.Add("--config-source"); + global.Add(args[++i]); + } + else if (args[i] == "--skip-private-repositories") + global.Add("--skip-private-repositories"); + else + remaining.Add(args[i]); + } + args = [.. remaining]; + return [.. global]; + } } diff --git a/aspire/aspire.csproj b/aspire/aspire.csproj index 93a127ff2b..5c1073d0e9 100644 --- a/aspire/aspire.csproj +++ b/aspire/aspire.csproj @@ -10,14 +10,11 @@ 72f50f33-6fb9-4d08-bff3-39568fe370b3 false Elastic.Documentation.Aspire - IDE0350 + IDE0350;IDE0060 - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index 4b3a497eb0..dbe719144f 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; @@ -19,19 +20,23 @@ namespace Elastic.Documentation.ServiceDefaults; public static class AppDefaultsExtensions { public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var args = Array.Empty(); - return builder.AddDocumentationServiceDefaults(ref args); - } + => builder.AddDocumentationServiceDefaults([], null); + + /// Backward-compatible overload — are scanned but no longer modified. public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, ref string[] args, Action? configure = null) where TBuilder : IHostApplicationBuilder + => builder.AddDocumentationServiceDefaults(args, configure); + + public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, string[] args, Action? configure = null) + where TBuilder : IHostApplicationBuilder { - GlobalCli.Process(ref args, out var globalArgs); + var cliArgs = GlobalCli.ScanArgs(args); + var isMcp = GlobalCli.IsMcpMode(args); var services = builder.Services; - _ = builder.Services.AddElasticDocumentationLogging(globalArgs.LogLevel, noConsole: globalArgs.IsMcp); + _ = services.AddElasticDocumentationLogging(cliArgs.LogLevel, noConsole: isMcp); _ = services - .AddConfigurationFileProvider(globalArgs.SkipPrivateRepositories, globalArgs.ConfigurationSource, (s, p) => + .AddConfigurationFileProvider(cliArgs.SkipPrivateRepositories, cliArgs.ConfigurationSource, (s, p) => { var versionConfiguration = p.CreateVersionConfiguration(); var products = p.CreateProducts(versionConfiguration); @@ -42,8 +47,7 @@ public static TBuilder AddDocumentationServiceDefaults(this TBuilder b _ = s.AddSingleton(search); configure?.Invoke(s, p); }); - _ = builder.Services.AddElasticDocumentationLogging(globalArgs.LogLevel, noConsole: globalArgs.IsMcp); - _ = services.AddSingleton(globalArgs); + _ = services.AddSingleton(cliArgs); var endpoints = ElasticsearchEndpointFactory.Create(builder.Configuration); _ = services.AddSingleton(endpoints); @@ -65,5 +69,4 @@ public static TServiceCollection AddElasticDocumentationLoggingEarly-parse utilities for use before the DI host is built. public static class GlobalCli { - public static void Process(ref string[] args, out GlobalCliArgs cli) => Process(ref args, out cli, out _); - public static void Process(ref string[] args, out GlobalCliArgs cli, out string[] globalArguments) + /// + /// Scans for known startup flags without modifying the array. + /// Used for pre-host setup before argh routing runs. + /// + public static GlobalCliArgs ScanArgs(string[] args) { - cli = new GlobalCliArgs(); - globalArguments = []; - var globalArgs = new List(); - var filteredArguments = new List(); + var options = new GlobalCliArgs(); for (var i = 0; i < args.Length; i++) { - if (args[i] == "--log-level") + if (args[i] == "--log-level" && i + 1 < args.Length) + options = options with { LogLevel = ParseLogLevel(args[++i]) }; + else if (args[i] is "--config-source" or "--configuration-source" or "-c" && i + 1 < args.Length) { - if (args.Length > i + 1) - { - cli = cli with { LogLevel = GetLogLevel(args[i + 1]) }; - globalArgs.Add("--log-level"); - globalArgs.Add(args[i + 1]); - } - i++; - } - else if (args[i] is "--config-source" or "--configuration-source" or "-c") - { - if (args.Length > i + 1 && ConfigurationSourceExtensions.TryParse(args[i + 1], out var cs, true, true)) - { - cli = cli with { ConfigurationSource = cs }; - globalArgs.Add("--config-source"); - globalArgs.Add(args[i + 1]); - } + if (ConfigurationSourceExtensions.TryParse(args[i + 1], out var cs, true, true)) + options = options with { ConfigurationSource = cs }; i++; } else if (args[i] == "--skip-private-repositories") - { - cli = cli with { SkipPrivateRepositories = true }; - globalArgs.Add("--skip-private-repositories"); - } - else if (args[i] is "--help" or "--version") - { - cli = cli with { IsHelpOrVersion = true }; - globalArgs.Add(args[i]); - filteredArguments.Add(args[i]); - } - else - filteredArguments.Add(args[i]); + options = options with { SkipPrivateRepositories = true }; } - - args = [.. filteredArguments]; - globalArguments = [.. globalArgs]; - - if (filteredArguments.Count > 0 && filteredArguments[0] == "mcp") - cli = cli with { IsMcp = true }; + return options; } - private static LogLevel GetLogLevel(string? logLevel) => logLevel switch + /// Returns when the first non-flag argument is mcp. + public static bool IsMcpMode(string[] args) => args.Length > 0 && args[0] == "mcp"; + + private static LogLevel ParseLogLevel(string? logLevel) => logLevel switch { "trace" => LogLevel.Trace, "debug" => LogLevel.Debug, @@ -78,5 +46,12 @@ public static void Process(ref string[] args, out GlobalCliArgs cli, out string[ "critical" => LogLevel.Critical, _ => LogLevel.Information }; +} +/// Startup args parsed before the DI host builds (not injected into commands). +public record GlobalCliArgs +{ + public LogLevel LogLevel { get; init; } = LogLevel.Information; + public ConfigurationSource? ConfigurationSource { get; init; } + public bool SkipPrivateRepositories { get; init; } } diff --git a/src/tooling/docs-builder/Arguments/ExportOption.cs b/src/tooling/docs-builder/Arguments/ExportOption.cs index 980378613c..e09b746333 100644 --- a/src/tooling/docs-builder/Arguments/ExportOption.cs +++ b/src/tooling/docs-builder/Arguments/ExportOption.cs @@ -2,23 +2,32 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using ConsoleAppFramework; using Elastic.Documentation; +using Nullean.Argh.Parsing; using static Elastic.Documentation.Exporter; namespace Documentation.Builder.Arguments; -[AttributeUsage(AttributeTargets.Parameter)] -public class ExporterParserAttribute : Attribute, IArgumentParser> +/// +/// Parses a comma-separated exporter list into . +/// Use with [ArgumentParser(typeof(ExporterParser))] on command parameters. +/// +/// +/// Accepted values: html, es / elasticsearch, config, links, +/// state, llm / llmtext, redirect / redirects, metadata, +/// none, default. +/// +public class ExporterParser : IArgumentParser> { - public static bool TryParse(ReadOnlySpan s, out IReadOnlySet result) + public bool TryParse(string raw, out IReadOnlySet result) { result = ExportOptions.Default; var set = new HashSet(); - var options = s.Split(','); + var span = raw.AsSpan(); + var options = span.Split(','); foreach (var option in options) { - var export = s[option].Trim().ToString().ToLowerInvariant() switch + var export = span[option].Trim().ToString().ToLowerInvariant() switch { "llm" => LLMText, "llmtext" => LLMText, @@ -33,7 +42,7 @@ public static bool TryParse(ReadOnlySpan s, out IReadOnlySet res "none" => null, "default" => AddDefaultReturnNull(set, ExportOptions.Default), "metadata" => AddDefaultReturnNull(set, ExportOptions.MetadataOnly), - _ => throw new Exception($"Unknown exporter {s[option].Trim().ToString().ToLowerInvariant()}") + _ => throw new Exception($"Unknown exporter {span[option].Trim().ToString().ToLowerInvariant()}") }; if (export.HasValue) _ = set.Add(export.Value); diff --git a/src/tooling/docs-builder/Arguments/ProductInfoParser.cs b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs index 3c41f7d6d3..b0e486202f 100644 --- a/src/tooling/docs-builder/Arguments/ProductInfoParser.cs +++ b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs @@ -2,40 +2,51 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using ConsoleAppFramework; +using System.Collections; using Elastic.Changelog; +using Nullean.Argh.Parsing; namespace Documentation.Builder.Arguments; -[AttributeUsage(AttributeTargets.Parameter)] -public class ProductInfoParserAttribute : Attribute, IArgumentParser> +/// +/// Wrapper for a parsed list of entries. +/// Use with [ArgumentParser(typeof(ProductInfoParser))] on command parameters. +/// +/// +/// Input: comma-separated entries, each space-separated as product target lifecycle. +/// Example: elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05 ga +/// +public sealed class ProductArgumentList(List items) : IReadOnlyList { - public static bool TryParse(ReadOnlySpan s, out List result) - { - result = []; + private readonly List _items = items; + + public int Count => _items.Count; + public ProductArgument this[int index] => _items[index]; + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); - // Split by comma to get individual product entries - var productEntries = s.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + public static implicit operator List(ProductArgumentList v) => v._items; +} - foreach (var entry in productEntries) +/// Parses a comma-separated product list into a . +public class ProductInfoParser : IArgumentParser +{ + public bool TryParse(string raw, out ProductArgumentList result) + { + var parsed = new List(); + foreach (var entry in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - // Split by whitespace to get product, target, lifecycle var parts = entry.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length == 0) continue; - - var productInfo = new ProductArgument + parsed.Add(new ProductArgument { Product = parts[0], Target = parts.Length > 1 ? parts[1] : null, Lifecycle = parts.Length > 2 ? parts[2] : null - }; - - result.Add(productInfo); + }); } - - return result.Count > 0; + result = new ProductArgumentList(parsed); + return parsed.Count > 0; } } - diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index 5c3cf9f25b..eaaa3d5a20 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -4,21 +4,28 @@ using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; using Documentation.Builder.Arguments; using Documentation.Builder.Http; using Elastic.Documentation; using Elastic.Documentation.Assembler.Building; +using Elastic.Documentation.Assembler.Configuration; using Elastic.Documentation.Assembler.Sourcing; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; -internal sealed class AssembleCommands( +/// +/// Full assembler pipeline in one shot: init configuration, clone all repositories, then build assembled documentation. +/// +/// +/// Hoisted to the root scope via app.Map<AssembleOneShotCommand>() as the assemble command. +/// +internal sealed class AssembleOneShotCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, AssemblyConfiguration assemblyConfiguration, @@ -27,22 +34,32 @@ internal sealed class AssembleCommands( IEnvironmentVariables environmentVariables ) { - /// Do a full assembler clone and assembler build in one swoop - /// Treat warnings as errors and fail the build on warnings - /// The environment to build - /// If true, fetch the latest commit of the branch instead of the link registry entry ref - /// If true, assume the repository folder already exists on disk assume it's cloned already, primarily used for testing - /// If true, assume the build output already exists and skip building if index.html exists, primarily used for testing - /// Only emit documentation metadata to output, ignored if 'exporters' is also set - /// Show hints from all documentation sets during assembler build - /// Set available exporters: - /// html, es, config, links, state, llm, redirect, metadata, none. - /// Defaults to (html, config, links, state, redirect) or 'default'. + /// + /// Full assembler pipeline: init configuration, clone all repositories, then build assembled documentation. + /// + /// + /// Equivalent to running assembler config init, assembler clone, and assembler build in sequence. + /// + /// docs-builder assemble + /// docs-builder assemble --environment staging --fetch-latest --exporters html,es + /// docs-builder assemble --serve + /// + /// + /// Treat warnings as errors and fail the build on warnings + /// The environment to build + /// Fetch the latest commit of the branch instead of the link registry entry ref + /// Assume the repository folder already exists on disk (skip clone); primarily used for testing + /// Assume the build output already exists (skip build if index.html exists); primarily used for testing + /// Only emit documentation metadata to output (ignored when --exporters is also set) + /// Show hints from all documentation sets during the build + /// + /// Comma-separated exporter list. Values: html, es, config, links, state, llm, redirect, metadata, none, default. + /// Default: (html, config, links, state, redirect). /// - /// Serve the documentation on port 4000 after succesful build - /// - [Command("")] - public async Task CloneAndBuild( + /// Serve the documentation on port 4000 after a successful build + [CommandName("assemble")] + public async Task Assemble( + GlobalCliOptions _, bool? strict = null, string? environment = null, bool? fetchLatest = null, @@ -50,15 +67,15 @@ public async Task CloneAndBuild( bool? assumeBuild = null, bool? metadataOnly = null, bool? showHints = null, - [ExporterParser] IReadOnlySet? exporters = null, + [ArgumentParser(typeof(ExporterParser))] IReadOnlySet? exporters = null, bool serve = false, - Cancel ctx = default + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); var cloneService = new AssemblerCloneService(logFactory, assemblyConfiguration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(cloneService, (strict, environment, fetchLatest, assumeCloned, ctx), strict ?? false, + serviceInvoker.AddCommand(cloneService, (strict, environment, fetchLatest, assumeCloned), strict ?? false, static async (s, collector, state, ctx) => await s.CloneAll(collector, state.strict, state.environment, state.fetchLatest, state.assumeCloned, ctx) ); @@ -69,17 +86,16 @@ static async (s, collector, state, ctx) => await s.CloneAll(collector, state.str static async (s, collector, state, ctx) => await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.readFs, state.writeFs, ctx) ); - var result = await serviceInvoker.InvokeAsync(ctx); + var result = await serviceInvoker.InvokeAsync(ct); if (serve && result == 0) { var host = new StaticWebHost(4000, null); - await host.RunAsync(ctx); - await host.StopAsync(ctx); + await host.RunAsync(ct); + await host.StopAsync(ct); } return result; - } } @@ -92,52 +108,49 @@ internal sealed class AssemblerCommands( IEnvironmentVariables environmentVariables ) { - /// Clones all repositories - /// Treat warnings as errors and fail the build on warnings - /// The environment to build - /// If true, fetch the latest commit of the branch instead of the link registry entry ref - /// If true, assume the repository folder already exists on disk assume it's cloned already, primarily used for testing - /// - [Command("clone")] - public async Task CloneAll( + /// Clone all repositories configured in the assembler. + /// Treat warnings as errors and fail the build on warnings + /// The environment to clone for + /// Fetch the latest commit of the branch instead of the link registry entry ref + /// Assume repositories are already cloned; primarily used for testing + [NoOptionsInjection] + public async Task Clone( bool? strict = null, string? environment = null, bool? fetchLatest = null, bool? assumeCloned = null, - Cancel ctx = default + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new AssemblerCloneService(logFactory, assemblyConfiguration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(service, (strict, environment, fetchLatest, assumeCloned, ctx), strict ?? false, + serviceInvoker.AddCommand(service, (strict, environment, fetchLatest, assumeCloned), strict ?? false, static async (s, collector, state, ctx) => await s.CloneAll(collector, state.strict, state.environment, state.fetchLatest, state.assumeCloned, ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - - /// Builds all repositories - /// Treat warnings as errors and fail the build on warnings - /// The environment to build - /// If true, assume the build output already exists and skip building if index.html exists, primarily used for testing - /// Only emit documentation metadata to output, ignored if 'exporters' is also set - /// Show hints from all documentation sets during assembler build - /// Set available exporters: - /// html, es, config, links, state, llm, redirect, metadata, none. - /// Defaults to (html, config, links, state, redirect) or 'default'. + /// Build all cloned repositories into assembled documentation. + /// Treat warnings as errors and fail the build on warnings + /// The environment to build + /// Assume the build output already exists; primarily used for testing + /// Only emit documentation metadata to output (ignored when --exporters is also set) + /// Show hints from all documentation sets during the build + /// + /// Comma-separated exporter list. Values: html, es, config, links, state, llm, redirect, metadata, none, default. + /// Default: (html, config, links, state, redirect). /// - /// - [Command("build")] - public async Task BuildAll( + [NoOptionsInjection] + public async Task Build( bool? strict = null, string? environment = null, bool? assumeBuild = null, bool? metadataOnly = null, bool? showHints = null, - [ExporterParser] IReadOnlySet? exporters = null, - Cancel ctx = default + [ArgumentParser(typeof(ExporterParser))] IReadOnlySet? exporters = null, + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -150,21 +163,18 @@ static async (s, collector, state, ctx) => await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.readFs, state.writeFs, ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// Serve the output of an assembler build - /// Port to serve the documentation. - /// - [Command("serve")] - public async Task ServeAssemblerBuild(int port = 4000, string? path = null, Cancel ctx = default) + /// Serve the output of an assembler build at http://localhost:4000. + /// Port to serve the documentation. Default: 4000 + /// Path to the built documentation. Defaults to the standard assembler output + [NoOptionsInjection] + public async Task Serve(int port = 4000, string? path = null, CancellationToken ct = default) { var host = new StaticWebHost(port, path); - await host.RunAsync(ctx); - await host.StopAsync(ctx); - // since this command does not use ServiceInvoker, we stop the collector manually. - // this should be an exception to the regular command pattern. - await collector.StopAsync(ctx); + await host.RunAsync(ct); + await host.StopAsync(ct); + await collector.StopAsync(ct); } - } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 7a8a5a9316..7c87ae9350 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -4,13 +4,15 @@ using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; + +using Elastic.Documentation; using Elastic.Documentation.Assembler.Indexing; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; @@ -24,100 +26,37 @@ IEnvironmentVariables environmentVariables ) { /// - /// Index documentation to Elasticsearch, calls `docs-builder assembler build --exporters elasticsearch`. Exposes more options + /// Index assembled documentation to Elasticsearch. /// - /// -es, Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL - /// The --environment used to clone ends up being part of the index name - /// Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY - /// Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME - /// Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD - /// Disable AI enrichment of documents using LLM-generated metadata (enabled by default) - /// The number of search threads the inference endpoint should use. Defaults: 8 - /// The number of index threads the inference endpoint should use. Defaults: 8 - /// Do not use the Elastic Inference Service, bootstrap inference endpoint - /// Force reindex strategy to semantic index - /// Timeout in minutes for the inference endpoint creation. Defaults: 4 - /// The number of documents to send to ES as part of the bulk. Defaults: 100 - /// The number of times failed bulk items should be retried. Defaults: 3 - /// Buffer ES request/responses for better error messages and pass ?pretty to all requests - /// Route requests through a proxy server - /// Proxy server password - /// Proxy server username - /// Disable SSL certificate validation (EXPERT OPTION) - /// Pass a self-signed certificate fingerprint to validate the SSL connection - /// Pass a self-signed certificate to validate the SSL connection - /// If the certificate is not root but only part of the validation chain pass this - /// - /// - [Command("")] + /// + /// Calls docs-builder assembler build --exporters elasticsearch with full Elasticsearch option control. + /// + /// docs-builder assembler index --endpoint https://es:9200 --api-key KEY --environment staging + /// + /// + /// The environment name; becomes part of the index name + [CommandName("index")] public async Task Index( - string? endpoint = null, + GlobalCliOptions _, + [AsParameters] ElasticsearchIndexOptions es, string? environment = null, - string? apiKey = null, - string? username = null, - string? password = null, - - // inference options - bool? noAiEnrichment = null, - int? searchNumThreads = null, - int? indexNumThreads = null, - bool? noEis = null, - int? bootstrapTimeout = null, - - // index options - bool? forceReindex = null, - - // channel buffer options - int? bufferSize = null, - int? maxRetries = null, - - // connection options - bool? debugMode = null, - - // proxy options - string? proxyAddress = null, - string? proxyPassword = null, - string? proxyUsername = null, - - // certificate options - bool? disableSslVerification = null, - string? certificateFingerprint = null, - string? certificatePath = null, - bool? certificateNotRoot = null, - Cancel ctx = default + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); var readFs = FileSystemFactory.RealRead; var writeFs = FileSystemFactory.RealWrite; var service = new AssemblerIndexService(logFactory, configuration, configurationContext, githubActionsService, environmentVariables); - var state = (readFs, writeFs, - // endpoint options - endpoint, environment, apiKey, username, password, - // inference options - noAiEnrichment, indexNumThreads, searchNumThreads, noEis, bootstrapTimeout, - // channel and connection options - forceReindex, bufferSize, maxRetries, debugMode, - // proxy options - proxyAddress, proxyPassword, proxyUsername, - // certificate options - disableSslVerification, certificateFingerprint, certificatePath, certificateNotRoot - ); + var state = (readFs, writeFs, environment, es); serviceInvoker.AddCommand(service, state, static async (s, collector, state, ctx) => await s.Index(collector, state.readFs, state.writeFs, - // endpoint options - state.endpoint, state.environment, state.apiKey, state.username, state.password, - // inference options - state.noAiEnrichment, state.searchNumThreads, state.indexNumThreads, state.noEis, state.bootstrapTimeout, - // channel and connection options - state.forceReindex, state.bufferSize, state.maxRetries, state.debugMode, - // proxy options - state.proxyAddress, state.proxyPassword, state.proxyUsername, - // certificate options - state.disableSslVerification, state.certificateFingerprint, state.certificatePath, state.certificateNotRoot - , ctx) + state.es.Endpoint?.ToString(), state.environment, state.es.ApiKey, state.es.Username, state.es.Password, + state.es.NoAiEnrichment, state.es.SearchNumThreads, state.es.IndexNumThreads, state.es.NoEis, state.es.BootstrapTimeout, + state.es.ForceReindex, state.es.BufferSize, state.es.MaxRetries, state.es.DebugMode, + state.es.ProxyAddress?.ToString(), state.es.ProxyPassword, state.es.ProxyUsername, + state.es.DisableSslVerification, state.es.CertificateFingerprint, state.es.CertificatePath, state.es.CertificateNotRoot, + ctx) ); - - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index 9d066babe9..42812baef5 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -4,13 +4,15 @@ using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; + +using Elastic.Documentation; using Elastic.Documentation.Assembler.Building; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; @@ -23,57 +25,33 @@ ICoreService githubActionsService ) { /// - /// Generate sitemap.xml from the Elasticsearch index with correct content_last_updated dates + /// Generate sitemap.xml from the Elasticsearch index using content_last_updated dates. /// - /// -es, Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL - /// The --environment used to resolve the ES index name - /// Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY - /// Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME - /// Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD - /// Buffer ES request/responses for better error messages and pass ?pretty to all requests - /// Route requests through a proxy server - /// Proxy server password - /// Proxy server username - /// Disable SSL certificate validation (EXPERT OPTION) - /// Pass a self-signed certificate fingerprint to validate the SSL connection - /// Pass a self-signed certificate to validate the SSL connection - /// If the certificate is not root but only part of the validation chain pass this - /// - /// - [Command("")] + /// + /// + /// docs-builder assembler sitemap --endpoint https://es:9200 --api-key KEY --environment staging + /// + /// + /// The environment name used to resolve the Elasticsearch index + [CommandName("sitemap")] public async Task Sitemap( - string? endpoint = null, + GlobalCliOptions _, + [AsParameters] ElasticsearchIndexOptions es, string? environment = null, - string? apiKey = null, - string? username = null, - string? password = null, - bool? debugMode = null, - string? proxyAddress = null, - string? proxyPassword = null, - string? proxyUsername = null, - bool? disableSslVerification = null, - string? certificateFingerprint = null, - string? certificatePath = null, - bool? certificateNotRoot = null, - Cancel ctx = default + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealWrite; var service = new AssemblerSitemapService(logFactory, configuration, configurationContext, githubActionsService); - var state = (fs, - endpoint, environment, apiKey, username, password, - debugMode, proxyAddress, proxyPassword, proxyUsername, - disableSslVerification, certificateFingerprint, certificatePath, certificateNotRoot - ); + var state = (fs, environment, es); serviceInvoker.AddCommand(service, state, - static async (s, col, state, ct) => await s.GenerateSitemapAsync(col, state.fs, - state.endpoint, state.environment, state.apiKey, state.username, state.password, - state.debugMode, state.proxyAddress, state.proxyPassword, state.proxyUsername, - state.disableSslVerification, state.certificateFingerprint, state.certificatePath, state.certificateNotRoot, - ct) + static async (s, col, state, ctx) => await s.GenerateSitemapAsync(col, state.fs, + state.es.Endpoint?.ToString(), state.environment, state.es.ApiKey, state.es.Username, state.es.Password, + state.es.DebugMode, state.es.ProxyAddress?.ToString(), state.es.ProxyPassword, state.es.ProxyUsername, + state.es.DisableSslVerification, state.es.CertificateFingerprint, state.es.CertificatePath, state.es.CertificateNotRoot, + ctx) ); - - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs index 289468a847..d45136037b 100644 --- a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs @@ -2,21 +2,21 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.LegacyDocs; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; internal sealed class BloomFilterCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector) { - /// Generate the bloom filter binary file - /// The local dir of local elastic/built-docs repository - /// - [Command("create")] - public async Task CreateBloomBin(string builtDocsDir, Cancel ctx = default) + /// Generate the bloom filter binary file from a local elastic/built-docs repository. + /// Path to the local elastic/built-docs repository + [NoOptionsInjection] + public async Task Create(string builtDocsDir, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -28,14 +28,13 @@ public async Task CreateBloomBin(string builtDocsDir, Cancel ctx = default) var result = s.GenerateBloomFilterBinary(pagesProvider); return Task.FromResult(result); }); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// Lookup whether exists in the bloomfilter - /// The local dir of local elastic/built-docs repository - /// - [Command("lookup")] - public async Task PageExists(string path, Cancel ctx = default) + /// Look up whether a path exists in the bloom filter. + /// The URL path to look up + [NoOptionsInjection] + public async Task Lookup(string path, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -45,6 +44,6 @@ public async Task PageExists(string path, Cancel ctx = default) var result = s.PathExists(path, logResult: true); return Task.FromResult(result); }); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs index 0cea37f2cb..7a9374f9c2 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs @@ -3,28 +3,28 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Assembler.Configuration; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; -internal sealed class ConfigurationCommands( +internal sealed class ConfigurationCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, AssemblyConfiguration assemblyConfiguration ) { - /// Clone the configuration folder - /// The git reference of the config, defaults to 'main' - /// Save the remote configuration locally in the pwd so later commands can pick it up as local - /// - [Command("init")] - public async Task CloneConfigurationFolder(string? gitRef = null, bool local = false, Cancel ctx = default) + /// Clone the assembler configuration folder into application data. + /// Git reference to clone. Defaults to main + /// Save the remote configuration locally in pwd so later commands can pick it up as a local source + [NoOptionsInjection] + public async Task Init(string? gitRef = null, bool local = false, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -32,6 +32,6 @@ public async Task CloneConfigurationFolder(string? gitRef = null, bool loca var service = new ConfigurationCloneService(logFactory, assemblyConfiguration, fs); serviceInvoker.AddCommand(service, (gitRef, local), static async (s, collector, state, ctx) => await s.InitConfigurationToApplicationData(collector, state.gitRef, state.local, ctx)); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index 21080a6fac..0ae6e094d7 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -4,13 +4,14 @@ using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Assembler.ContentSources; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; @@ -22,8 +23,9 @@ internal sealed class ContentSourceCommands( ICoreService githubActionsService ) { - [Command("validate")] - public async Task Validate(Cancel ctx = default) + /// Validate that all configured repositories have been published. + [NoOptionsInjection] + public async Task Validate(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -31,15 +33,14 @@ public async Task Validate(Cancel ctx = default) var service = new RepositoryPublishValidationService(logFactory, configuration, configurationContext, fs); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.ValidatePublishStatus(collector, ctx)); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// - /// - /// - /// - [Command("match")] - public async Task Match([Argument] string? repository = null, [Argument] string? branchOrTag = null, Cancel ctx = default) + /// Match a repository to a branch or tag and determine whether it should be built. + /// Repository to match + /// Branch or tag to match against + [NoOptionsInjection] + public async Task Match([Argument] string? repository = null, [Argument] string? branchOrTag = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -49,12 +50,9 @@ public async Task Match([Argument] string? repository = null, [Argument] st static async (s, collector, state, ctx) => { _ = await s.ShouldBuild(collector, state.repository, state.branchOrTag, ctx); - // ShouldBuild throws an exception on bad args and will return false if it has no matches - // We return true to the service invoker to continue return true; }); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - } diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index ac307d213f..36e790b944 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -3,13 +3,14 @@ // See the LICENSE file in the project root for more information using Actions.Core.Services; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Assembler.Deploying; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; @@ -21,14 +22,13 @@ internal sealed class DeployCommands( ICoreService githubActionsService ) { - /// Creates a sync plan - /// The environment to build + /// Create a sync plan for deploying built documentation to S3. + /// The environment to deploy to /// The S3 bucket name to deploy to - /// The file to write the plan to - /// The percentage of deletions allowed in the plan as float - /// - [Command("plan")] - public async Task Plan(string environment, string s3BucketName, string @out = "", float? deleteThreshold = null, Cancel ctx = default) + /// The file path to write the plan to + /// Maximum percentage of deletions allowed in the plan (as a float) + [NoOptionsInjection] + public async Task Plan(string environment, string s3BucketName, string @out = "", float? deleteThreshold = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -36,16 +36,15 @@ public async Task Plan(string environment, string s3BucketName, string @out serviceInvoker.AddCommand(service, (environment, s3BucketName, @out, deleteThreshold), static async (s, collector, state, ctx) => await s.Plan(collector, state.environment, state.s3BucketName, state.@out, state.deleteThreshold, ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// Applies a sync plan - /// The environment to build + /// Apply a previously generated sync plan to deploy documentation to S3. + /// The environment to deploy to /// The S3 bucket name to deploy to - /// The file path to the plan file to apply - /// - [Command("apply")] - public async Task Apply(string environment, string s3BucketName, string planFile, Cancel ctx = default) + /// Path to the plan file generated by assembler deploy plan + [NoOptionsInjection] + public async Task Apply(string environment, string s3BucketName, string planFile, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -53,15 +52,14 @@ public async Task Apply(string environment, string s3BucketName, string pla serviceInvoker.AddCommand(service, (environment, s3BucketName, planFile), static async (s, collector, state, ctx) => await s.Apply(collector, state.environment, state.s3BucketName, state.planFile, ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// Refreshes the redirects mapping in Cloudfront's KeyValueStore - /// The environment to build - /// Path to the redirects mapping pre-generated by docs-builder assemble - /// - [Command("update-redirects")] - public async Task UpdateRedirects(string environment, string? redirectsFile = null, Cancel ctx = default) + /// Refresh the redirects mapping in CloudFront's KeyValueStore. + /// The environment whose redirects to update + /// Path to the redirects mapping pre-generated by assembler build + [NoOptionsInjection] + public async Task UpdateRedirects(string environment, string? redirectsFile = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -70,7 +68,6 @@ public async Task UpdateRedirects(string environment, string? redirectsFile serviceInvoker.AddCommand(service, (environment, redirectsFile), static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile, ctx: ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - } diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index d6ca3075f9..07e9b2e2ce 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -3,13 +3,14 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Assembler.Navigation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Assembler; @@ -20,27 +21,24 @@ internal sealed class NavigationCommands( IConfigurationContext configurationContext ) { - /// Validates navigation.yml does not contain colliding path prefixes and all urls are unique - /// - [Command("validate")] - public async Task Validate(Cancel ctx = default) + /// Validate that navigation.yml has no colliding path prefixes and all URLs are unique. + [NoOptionsInjection] + public async Task Validate(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.Validate(collector, ctx)); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// Validate all published links in links.json do not collide with navigation path_prefixes and all urls are unique. - /// Path to `links.json` defaults to '.artifacts/docs/html/links.json' - /// - [Command("validate-link-reference")] - public async Task ValidateLocalLinkReference([Argument] string? file = null, Cancel ctx = default) + /// Validate that all links in a local links.json do not collide with navigation path prefixes. + /// Path to links.json. Defaults to .artifacts/docs/html/links.json + [NoOptionsInjection] + public async Task ValidateLinkReference([Argument] string? file = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); serviceInvoker.AddCommand(service, file, static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file, ctx)); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index a47cd20ec8..b2d9017407 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -7,9 +7,9 @@ using System.Text; using System.Text.RegularExpressions; using Actions.Core.Services; -using ConsoleAppFramework; using Documentation.Builder.Arguments; using Elastic.Changelog; +using Nullean.Argh; using Elastic.Changelog.Bundling; using Elastic.Changelog.Configuration; using Elastic.Changelog.Creation; @@ -26,7 +26,7 @@ namespace Documentation.Builder.Commands; -internal sealed partial class ChangelogCommand( +internal sealed partial class ChangelogCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, IConfigurationContext configurationContext, @@ -41,28 +41,18 @@ IEnvironmentVariables environmentVariables private static partial Regex BundleOutputDirectoryRegex(); private readonly IFileSystem _fileSystem = FileSystemFactory.RealRead; - private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly ILogger _logger = logFactory.CreateLogger(); /// - /// Changelog commands. Use 'changelog add' to create a new changelog or 'changelog bundle' to create a consolidated list of changelogs. - /// - [Command("")] - public Task Default() - { - collector.EmitError(string.Empty, "Please specify a subcommand. Available subcommands:\n - 'changelog add': Create a new changelog from command-line input\n - 'changelog bundle': Create a consolidated list of changelog files\n - 'changelog init': Initialize changelog configuration and folder structure\n - 'changelog render': Render a bundled changelog to markdown or asciidoc files\n - 'changelog upload': Upload changelog or bundle artifacts to S3 or Elasticsearch\n - 'changelog gh-release': Create changelogs from a GitHub release\n - 'changelog evaluate-pr': (CI) Evaluate a PR for changelog generation eligibility\n\nRun 'changelog --help' for usage information."); - return Task.FromResult(1); - } - - /// - /// Initialize changelog configuration and folder structure. Creates changelog.yml from the example template in the docs folder (discovered via docset.yml when present, or at {path}/docs which is created if needed), and creates changelog and releases subdirectories if they do not exist. + /// Initialize changelog configuration and folder structure. Creates changelog.yml from the example template in the docs folder (discovered via docset.yml when present, or at PATH/docs which is created if needed), and creates changelog and releases subdirectories if they do not exist. /// When changelog.yml already exists and --changelog-dir or --bundles-dir is specified, updates the bundle.directory and/or bundle.output_directory fields accordingly. /// When creating a new changelog.yml, seeds bundle.owner, bundle.repo, and bundle.link_allow_repos from git remote origin (github.com only) and/or --owner / --repo. /// - /// Optional: Repository root path. Defaults to the output of pwd (current directory). Docs folder is {path}/docs, created if it does not exist. - /// Optional: Path to changelog directory. Defaults to {docsFolder}/changelog. - /// Optional: Path to bundles output directory. Defaults to {docsFolder}/releases. + /// Optional: Repository root path. Defaults to the output of pwd (current directory). Docs folder is PATH/docs, created if it does not exist. + /// Optional: Path to changelog directory. Defaults to DOCS_FOLDER/changelog. + /// Optional: Path to bundles output directory. Defaults to DOCS_FOLDER/releases. /// Optional: GitHub owner for bundle defaults and link_allow_repos seeding. Overrides the owner inferred from git remote origin. /// Optional: GitHub repository name for bundle defaults and link_allow_repos seeding. Overrides the repo inferred from git remote origin. - [Command("init")] + [NoOptionsInjection] public Task Init( string? path = null, string? changelogDir = null, @@ -103,7 +93,7 @@ public Task Init( if (!_fileSystem.File.Exists(configPath)) { byte[]? templateBytes = null; - using (var stream = typeof(ChangelogCommand).Assembly.GetManifestResourceStream("Documentation.Builder.changelog.example.yml")) + using (var stream = typeof(ChangelogCommands).Assembly.GetManifestResourceStream("Documentation.Builder.changelog.example.yml")) { if (stream == null) { @@ -241,9 +231,9 @@ public Task Init( /// Optional: Use issue numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its issues. Requires --prs or --issues. Mutually exclusive with --use-pr-number. /// Optional: GitHub release tag to fetch PRs from (e.g., "v9.2.0" or "latest"). When specified, creates one changelog per PR in the release notes. Requires --repo (or bundle.repo in changelog.yml). Mutually exclusive with --prs and --issues. Does not create a bundle; use 'changelog gh-release' for that. /// Cancellation token - [Command("add")] - public async Task Create( - [ProductInfoParser] List? products = null, + [NoOptionsInjection] + public async Task Add( + [ArgumentParser(typeof(ProductInfoParser))] ProductArgumentList? products = null, string? action = null, string[]? areas = null, bool concise = false, @@ -266,9 +256,10 @@ public async Task Create( string? type = null, bool usePrNumber = false, bool useIssueNumber = false, - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); // Mutual exclusivity: --release-version cannot be combined with --prs or --issues @@ -424,7 +415,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c } // Use provided products or empty list (service will infer from repo/config if empty) - var resolvedProducts = products ?? []; + var resolvedProducts = (IReadOnlyList?)products ?? []; if (usePrNumber && useIssueNumber) { @@ -497,8 +488,8 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Include all changelogs in the directory. /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// Optional: Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory - /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. In option-based mode, placeholders require --output-products to be explicitly specified. - /// Optional: Filter by feature IDs (comma-separated) or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out when the bundle is rendered (by CLI render or {changelog} directive). + /// Optional: Bundle description text with placeholder support. Supports VERSION, LIFECYCLE, OWNER, and REPO placeholders. Overrides bundle.description from config. In option-based mode, placeholders require --output-products to be explicitly specified. + /// Optional: Filter by feature IDs (comma-separated) or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out when the bundle is rendered (by CLI render or changelog directive). /// Optional: Skip auto-population of release date in the bundle. Mutually exclusive with --release-date. Not available in profile mode. /// Optional: Explicit release date for the bundle in YYYY-MM-DD format. Overrides auto-population behavior. Mutually exclusive with --no-release-date. Not available in profile mode. /// Filter by products in format "product target lifecycle, ..." (for example, "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: "elasticsearch * *" matches all elasticsearch changelogs, "cloud-serverless 2025-12-02 *" matches cloud-serverless 2025-12-02 with any lifecycle, "* 9.3.* *" matches any product with target starting with "9.3.", "* * *" matches all changelogs (equivalent to --all). @@ -511,10 +502,9 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// A URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. /// GitHub release tag to use as a filter source (for example, "v9.2.0" or "latest"). When specified, fetches the release, parses PR references from the release notes, and uses those PRs as the filter — equivalent to passing the PR list via --prs. When --output-products is not specified, it is inferred from the release tag and repository name. /// Optional: Copy the contents of each changelog file into the entries array. Uses config bundle.resolve or defaults to false. - /// Optional: Explicitly turn off resolve (overrides config). /// Emit GitHub Actions step outputs (needs_network, needs_github_token, output_path) describing network requirements and the resolved output path, then exit without generating the bundle. Intended for CI actions. /// - [Command("bundle")] + [NoOptionsInjection] public async Task Bundle( [Argument] string? profile = null, [Argument] string? profileArg = null, @@ -526,9 +516,9 @@ public async Task Bundle( string[]? hideFeatures = null, bool noReleaseDate = false, string? releaseDate = null, - [ProductInfoParser] List? inputProducts = null, + [ArgumentParser(typeof(ProductInfoParser))] ProductArgumentList? inputProducts = null, string? output = null, - [ProductInfoParser] List? outputProducts = null, + [ArgumentParser(typeof(ProductInfoParser))] ProductArgumentList? outputProducts = null, string[]? issues = null, string? owner = null, bool plan = false, @@ -537,10 +527,10 @@ public async Task Bundle( string? repo = null, string? report = null, bool? resolve = null, - bool noResolve = false, - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); var service = new ChangelogBundlingService(logFactory, configurationContext); @@ -621,9 +611,7 @@ public async Task Bundle( if (!string.IsNullOrWhiteSpace(owner)) forbidden.Add("--owner"); if (resolve.HasValue) - forbidden.Add("--resolve"); - if (noResolve) - forbidden.Add("--no-resolve"); + forbidden.Add("--resolve / --no-resolve"); if (hideFeatures is { Length: > 0 }) forbidden.Add("--hide-features"); if (!string.IsNullOrWhiteSpace(config)) @@ -826,7 +814,7 @@ public async Task Bundle( } // Determine resolve: CLI --no-resolve and --resolve override config. null = use config default. - var shouldResolve = noResolve ? false : resolve; + var shouldResolve = resolve; var allFeatureIdsForBundle = ExpandCommaSeparated(hideFeatures); @@ -863,7 +851,7 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// /// Remove changelog files. Can use either profile-based removal (e.g., "remove elasticsearch-release 9.2.0") or raw flags (e.g., "remove --all"). /// When a file is referenced by an unresolved bundle, the command blocks by default to prevent breaking - /// the {changelog} directive. Use --force to override. + /// the changelog directive. Use --force to override. /// /// Optional: Profile name from bundle.profiles in config (for example, "elasticsearch-release"). When specified, the second argument is the version or promotion report URL. /// Optional: Version number or promotion report URL/path when using a profile (for example, "9.2.0" or "https://buildkite.../promotion-report.html") @@ -882,7 +870,7 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// GitHub repository name, which is used when PRs or issues are specified as numbers or when --release-version is used. Falls back to bundle.repo in changelog.yml when not specified. If that value is also absent, the product ID is used. /// Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, and --issues. /// - [Command("remove")] + [NoOptionsInjection] public async Task Remove( [Argument] string? profile = null, [Argument] string? profileArg = null, @@ -895,14 +883,15 @@ public async Task Remove( bool force = false, string[]? issues = null, string? owner = null, - [ProductInfoParser] List? products = null, + [ArgumentParser(typeof(ProductInfoParser))] ProductArgumentList? products = null, string[]? prs = null, string? releaseVersion = null, string? repo = null, string? report = null, - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); var service = new ChangelogRemoveService(logFactory, configurationContext); @@ -1104,7 +1093,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s /// Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false /// Optional: Title to use for section headers in output files. Defaults to version from first bundle /// - [Command("render")] + [NoOptionsInjection] public async Task Render( string[]? input = null, string? config = null, @@ -1113,9 +1102,10 @@ public async Task Render( string? output = null, bool subsections = false, string? title = null, - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); var service = new ChangelogRenderingService(logFactory, configurationContext); @@ -1161,14 +1151,14 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s /// Required: GitHub repository in owner/repo format (e.g., "elastic/elasticsearch" or just "elasticsearch" which defaults to elastic/elasticsearch) /// Optional: Version tag to fetch (e.g., "v9.0.0", "9.0.0"). Defaults to "latest" /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' - /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. + /// Optional: Bundle description text with placeholder support. Supports VERSION, LIFECYCLE, OWNER, and REPO placeholders. Overrides bundle.description from config. /// Optional: Output directory for changelog files. Falls back to bundle.directory in changelog.yml when not specified. Defaults to './changelogs' /// Optional: Explicit release date for the bundle in YYYY-MM-DD format. Overrides GitHub release published date. /// Optional: Remove square brackets and text within them from the beginning of PR titles (e.g., "[Inference API] Title" becomes "Title") /// Optional: Warn when the type inferred from release notes section headers doesn't match the type derived from PR labels. Defaults to true /// - [Command("gh-release")] - public async Task GitHubRelease( + [NoOptionsInjection] + public async Task GhRelease( [Argument] string repo, [Argument] string version = "latest", string? config = null, @@ -1177,9 +1167,10 @@ public async Task GitHubRelease( string? releaseDate = null, bool stripTitlePrefix = false, bool warnOnTypeMismatch = true, - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); // --output CLI > bundle.directory config > ./changelogs (service default) @@ -1225,18 +1216,16 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c /// /// Required: Path to the original bundle file to amend /// Required: Path(s) to changelog YAML file(s) to add as comma-separated values (e.g., --add "file1.yaml,file2.yaml"). Supports tilde (~) expansion and relative paths. - /// Optional: Copy the contents of each changelog file into the entries array. When not specified, inferred from the original bundle. - /// Optional: Explicitly turn off resolve (overrides inference from original bundle). - /// - [Command("bundle-amend")] + /// Optional: Copy the contents of each changelog file into the entries array. Use --no-resolve to explicitly turn off resolve (overrides inference from original bundle). + [NoOptionsInjection] public async Task BundleAmend( [Argument] string bundlePath, string[]? add = null, bool? resolve = null, - bool noResolve = false, - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); var service = new ChangelogBundleAmendService(logFactory, configurationContext: configurationContext); @@ -1258,7 +1247,7 @@ public async Task BundleAmend( .ToList(); // Determine resolve: CLI --no-resolve takes precedence, then CLI --resolve, then infer from bundle - var shouldResolve = noResolve ? false : resolve; + var shouldResolve = resolve; var input = new AmendBundleArguments { @@ -1291,7 +1280,7 @@ async static (s, collector, state, ctx) => await s.AmendBundle(collector, state, /// Remove square-bracket prefixes from the PR title /// Bot login name for loop detection /// - [Command("evaluate-pr")] + [NoOptionsInjection] public async Task EvaluatePr( string config, string owner, @@ -1306,9 +1295,10 @@ public async Task EvaluatePr( bool bodyChanged = false, bool stripTitlePrefix = false, string botName = "github-actions[bot]", - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); IGitHubPrService prService = new GitHubPrService(logFactory); @@ -1403,16 +1393,17 @@ private string ApplyChangelogInitBundleRepoSeed(string content, string? ownerCli /// S3 bucket name (required when target is 's3'). /// Path to changelog.yml configuration file. Defaults to docs/changelog.yml. /// Override changelog directory instead of reading it from config. - [Command("upload")] + [NoOptionsInjection] public async Task Upload( string artifactType, string target, string s3BucketName = "", string? config = null, string? directory = null, - Cancel ctx = default + CancellationToken ct = default ) { + var ctx = ct; if (!Enum.TryParse(artifactType, ignoreCase: true, out var parsedArtifactType)) { collector.EmitError(string.Empty, $"Invalid artifact type '{artifactType}'. Valid values: changelog, bundle"); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index a5d62a18cb..c3ca5f4383 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -4,11 +4,11 @@ using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; using Documentation.Builder.Http; using Elastic.Codex; using Elastic.Codex.Building; using Elastic.Codex.Sourcing; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Diagnostics; @@ -16,11 +16,12 @@ using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Codex; /// -/// Commands for building documentation codexes from multiple isolated documentation sets. +/// Build documentation codexes from multiple isolated documentation sets. /// internal sealed class CodexCommands( ILoggerFactory logFactory, @@ -33,27 +34,33 @@ IEnvironmentVariables environmentVariables /// /// Clone and build a documentation codex in one step. /// - /// Path to the codex.yml configuration file. - /// Treat warnings as errors and fail on warnings. - /// Fetch the latest commit even if already cloned. - /// Assume repositories are already cloned. - /// Output directory for the built codex. - /// Serve the documentation on port 4000 after build. - /// Cancellation token. - [Command("")] + /// + /// + /// docs-builder codex ./codex.yml + /// docs-builder codex ./codex.yml --strict --fetch-latest + /// docs-builder codex ./codex.yml --serve + /// + /// + /// Path to the codex.yml configuration file + /// Treat warnings as errors and fail on warnings + /// Fetch the latest commit even if already cloned + /// Assume repositories are already cloned + /// Output directory for the built codex + /// Serve the documentation on port 4000 after a successful build + [DefaultCommand] public async Task CloneAndBuild( + GlobalCliOptions _, [Argument] string config, bool strict = false, bool fetchLatest = false, bool assumeCloned = false, string? output = null, bool serve = false, - Cancel ctx = default) + CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; - // Load codex configuration var configPath = fs.Path.GetFullPath(config); var configFile = fs.FileInfo.New(configPath); @@ -84,45 +91,41 @@ public async Task CloneAndBuild( return cloneResult.Checkouts.Count > 0; }); - // Build service var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, async (s, col, state, c) => { - if (cloneResult == null) + if (state.cloneResult == null) return false; - var result = await s.BuildAll(state.codexContext, cloneResult, state.fs, c); + var result = await s.BuildAll(state.codexContext, state.cloneResult, state.fs, c); return result.DocumentationSets.Count > 0; }); - var result = await serviceInvoker.InvokeAsync(ctx); + var result = await serviceInvoker.InvokeAsync(ct); if (serve && result == 0) { var host = new StaticWebHost(4000, codexContext.OutputDirectory.FullName); - await host.RunAsync(ctx); - await host.StopAsync(ctx); + await host.RunAsync(ct); + await host.StopAsync(ct); } return result; } - /// - /// Clone all repositories defined in the codex configuration. - /// - /// Path to the codex.yml configuration file. - /// Treat warnings as errors and fail on warnings. - /// Fetch the latest commit even if already cloned. - /// Assume repositories are already cloned. - /// Cancellation token. - [Command("clone")] + /// Clone all repositories defined in the codex configuration. + /// Path to the codex.yml configuration file + /// Treat warnings as errors and fail on warnings + /// Fetch the latest commit even if already cloned + /// Assume repositories are already cloned + [NoOptionsInjection] public async Task Clone( [Argument] string config, bool strict = false, bool fetchLatest = false, bool assumeCloned = false, - Cancel ctx = default) + CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; @@ -155,22 +158,19 @@ public async Task Clone( return result.Checkouts.Count > 0; }); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// - /// Build all documentation sets from already cloned repositories. - /// - /// Path to the codex.yml configuration file. - /// Treat warnings as errors and fail on warnings. - /// Output directory for the built codex. - /// Cancellation token. - [Command("build")] + /// Build all documentation sets from already-cloned repositories. + /// Path to the codex.yml configuration file + /// Treat warnings as errors and fail on warnings + /// Output directory for the built codex + [NoOptionsInjection] public async Task Build( [Argument] string config, bool strict = false, string? output = null, - Cancel ctx = default) + CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; @@ -193,8 +193,7 @@ public async Task Build( } var codexContext = new CodexContext(codexConfig, configFile, collector, fs, fs, null, output); - - var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ctx); + var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); if (cloneResult == null || cloneResult.Checkouts.Count == 0) { @@ -211,30 +210,21 @@ public async Task Build( return result.DocumentationSets.Count > 0; }); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// - /// Serve the built codex documentation. - /// - /// Port to serve on. - /// Path to the codex output directory. - /// Cancellation token. - [Command("serve")] - public async Task Serve( - int port = 4000, - string? path = null, - Cancel ctx = default) + /// Serve the built codex documentation at http://localhost:4000. + /// Port to serve on. Default: 4000 + /// Path to the codex output directory + [NoOptionsInjection] + public async Task Serve(int port = 4000, string? path = null, CancellationToken ct = default) { var fs = FileSystemFactory.RealRead; - var servePath = path ?? fs.Path.Join( - Environment.CurrentDirectory, ".artifacts", "codex", "docs"); + var servePath = path ?? fs.Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); var host = new StaticWebHost(port, servePath); - await host.RunAsync(ctx); - await host.StopAsync(ctx); - - // Since this command doesn't use ServiceInvoker, stop collector manually - await collector.StopAsync(ctx); + await host.RunAsync(ct); + await host.StopAsync(ct); + await collector.StopAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 65d2b08ea9..1e2d34ce85 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -4,10 +4,11 @@ using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; + using Elastic.Codex; using Elastic.Codex.Indexing; using Elastic.Codex.Sourcing; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Diagnostics; @@ -15,12 +16,11 @@ using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Codex; -/// -/// Command for indexing codex documentation into Elasticsearch. -/// +/// Index codex documentation into Elasticsearch. internal sealed class CodexIndexCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, @@ -30,67 +30,19 @@ IEnvironmentVariables environmentVariables ) { /// - /// Index codex documentation to Elasticsearch. + /// Index a built codex documentation set to Elasticsearch. /// - /// Path to the codex configuration file. - /// -es, Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL - /// Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY - /// Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME - /// Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD - /// Disable AI enrichment of documents using LLM-generated metadata (enabled by default) - /// The number of search threads the inference endpoint should use. Defaults: 8 - /// The number of index threads the inference endpoint should use. Defaults: 8 - /// Do not use the Elastic Inference Service, bootstrap inference endpoint - /// Force reindex strategy to semantic index - /// Timeout in minutes for the inference endpoint creation. Defaults: 4 - /// The number of documents to send to ES as part of the bulk. Defaults: 100 - /// The number of times failed bulk items should be retried. Defaults: 3 - /// Buffer ES request/responses for better error messages and pass ?pretty to all requests - /// Route requests through a proxy server - /// Proxy server password - /// Proxy server username - /// Disable SSL certificate validation (EXPERT OPTION) - /// Pass a self-signed certificate fingerprint to validate the SSL connection - /// Pass a self-signed certificate to validate the SSL connection - /// If the certificate is not root but only part of the validation chain pass this - /// - /// - [Command("")] + /// + /// + /// docs-builder codex index ./codex.yml --endpoint https://es:9200 --api-key KEY + /// + /// + /// Path to the codex.yml configuration file public async Task Index( + GlobalCliOptions _, [Argument] string config, - string? endpoint = null, - string? apiKey = null, - string? username = null, - string? password = null, - - // inference options - bool? noAiEnrichment = null, - int? searchNumThreads = null, - int? indexNumThreads = null, - bool? noEis = null, - int? bootstrapTimeout = null, - - // index options - bool? forceReindex = null, - - // channel buffer options - int? bufferSize = null, - int? maxRetries = null, - - // connection options - bool? debugMode = null, - - // proxy options - string? proxyAddress = null, - string? proxyPassword = null, - string? proxyUsername = null, - - // certificate options - bool? disableSslVerification = null, - string? certificateFingerprint = null, - string? certificatePath = null, - bool? certificateNotRoot = null, - Cancel ctx = default + [AsParameters] ElasticsearchIndexOptions es, + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -117,7 +69,7 @@ public async Task Index( using var linkIndexReader = new GitLinkIndexReader(codexConfig.Environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); - var cloneResult = await cloneService.CloneAll(codexContext, fetchLatest: false, assumeCloned: true, ctx); + var cloneResult = await cloneService.CloneAll(codexContext, fetchLatest: false, assumeCloned: true, ct); if (cloneResult.Checkouts.Count == 0) { @@ -125,37 +77,13 @@ public async Task Index( return 1; } - var esOptions = new ElasticsearchIndexOptions - { - Endpoint = endpoint, - ApiKey = apiKey, - Username = username, - Password = password, - NoAiEnrichment = noAiEnrichment, - SearchNumThreads = searchNumThreads, - IndexNumThreads = indexNumThreads, - NoEis = noEis, - BootstrapTimeout = bootstrapTimeout, - ForceReindex = forceReindex, - BufferSize = bufferSize, - MaxRetries = maxRetries, - DebugMode = debugMode, - ProxyAddress = proxyAddress, - ProxyPassword = proxyPassword, - ProxyUsername = proxyUsername, - DisableSslVerification = disableSslVerification, - CertificateFingerprint = certificateFingerprint, - CertificatePath = certificatePath, - CertificateNotRoot = certificateNotRoot - }; - var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var service = new CodexIndexService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(service, (codexContext, cloneResult, fs, esOptions), + serviceInvoker.AddCommand(service, (codexContext, cloneResult, fs, es), static async (s, col, state, c) => - await s.Index(state.codexContext, state.cloneResult, state.fs, state.esOptions, c) + await s.Index(state.codexContext, state.cloneResult, state.fs, state.es, c) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index d888afcfc7..9a813c0620 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -3,35 +3,33 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Assembler.Deploying; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands.Codex; -/// -/// Command for updating CloudFront KeyValueStore redirects for codex. -/// +/// Update CloudFront KeyValueStore redirects for a codex deployment. internal sealed class CodexUpdateRedirectsCommand( IDiagnosticsCollector collector, ILoggerFactory logFactory ) { - /// Refreshes the redirects mapping in CloudFront's KeyValueStore for codex. - /// Path to the codex configuration file (used to resolve environment). - /// The environment to deploy to. Defaults to config or ENVIRONMENT env var. - /// Path to the redirects mapping. Defaults to .artifacts/codex/docs/redirects.json. - /// - [Command("")] - public async Task Run( + /// Refresh the redirects mapping in CloudFront's KeyValueStore for codex. + /// Path to the codex configuration file (used to resolve environment) + /// The environment to deploy to. Defaults to config or ENVIRONMENT env var + /// Path to the redirects mapping. Defaults to .artifacts/codex/docs/redirects.json + public async Task UpdateRedirects( + GlobalCliOptions _, [Argument] string config, string? environment = null, string? redirectsFile = null, - Cancel ctx = default) + CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -55,6 +53,6 @@ public async Task Run( serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile, state.kvsNamePrefix, state.defaultRedirectsFile, c) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/DiffCommands.cs b/src/tooling/docs-builder/Commands/DiffCommands.cs index c3d1d01097..1055863550 100644 --- a/src/tooling/docs-builder/Commands/DiffCommands.cs +++ b/src/tooling/docs-builder/Commands/DiffCommands.cs @@ -3,28 +3,29 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Refactor.Tracking; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands; -internal sealed class DiffCommands( +internal sealed class DiffCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, IConfigurationContext configurationContext ) { /// - /// Validates redirect updates in the current branch using the redirect file against changes reported by git. + /// Validate redirect updates in the current branch using the redirect file against changes reported by git. /// - /// -p, Defaults to the`{pwd}/docs` folder - /// - [Command("validate")] - public async Task ValidateRedirects(string? path = null, Cancel ctx = default) + /// -p, Defaults to the cwd/docs folder + [NoOptionsInjection] + [CommandName("diff")] + public async Task Validate(string? path = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -32,9 +33,8 @@ public async Task ValidateRedirects(string? path = null, Cancel ctx = defau var fs = FileSystemFactory.RealGitRootForPath(path); serviceInvoker.AddCommand(service, (path, fs), - async static (s, collector, state, _) => await s.ValidateRedirects(collector, state.path, state.fs) + async static (s, collector, state, _) => await s.ValidateRedirects(collector, state.path, state.fs) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - } diff --git a/src/tooling/docs-builder/Commands/FormatCommand.cs b/src/tooling/docs-builder/Commands/FormatCommand.cs deleted file mode 100644 index 2849bd3901..0000000000 --- a/src/tooling/docs-builder/Commands/FormatCommand.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.IO.Abstractions; -using ConsoleAppFramework; -using Elastic.Documentation.Configuration; -using Elastic.Documentation.Diagnostics; -using Elastic.Documentation.Refactor; -using Elastic.Documentation.Services; -using Microsoft.Extensions.Logging; - -namespace Documentation.Builder.Commands; - -internal sealed class FormatCommand( - ILoggerFactory logFactory, - IDiagnosticsCollector collector, - IConfigurationContext configurationContext -) -{ - /// - /// Format documentation files by fixing common issues like irregular space - /// - /// -p, Path to the documentation folder, defaults to pwd - /// Check if files need formatting without modifying them (exits with code 1 if formatting needed) - /// Write formatting changes to files - /// - [Command("")] - public async Task Format( - string? path = null, - bool check = false, - bool write = false, - Cancel ctx = default - ) - { - // Validate that exactly one of --check or --write is specified - if (check == write) - { - collector.EmitError(string.Empty, "Must specify exactly one of --check or --write"); - return 1; - } - - await using var serviceInvoker = new ServiceInvoker(collector); - - var service = new FormatService(logFactory, configurationContext); - var fs = FileSystemFactory.RealGitRootForPath(path); - - serviceInvoker.AddCommand(service, (path, check, fs), - async static (s, collector, state, ctx) => await s.Format(collector, state.path, state.check, state.fs, ctx) - ); - return await serviceInvoker.InvokeAsync(ctx); - } -} diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index 1bdc3220a7..f192055d34 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -3,12 +3,13 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Links.InboundLinks; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands; @@ -16,43 +17,40 @@ internal sealed class InboundLinkCommands(ILoggerFactory logFactory, IDiagnostic { private readonly LinkIndexService _linkIndexService = new(logFactory, FileSystemFactory.RealRead); - /// Validate all published cross_links in all published links.json files. - /// - [Command("validate-all")] - public async Task ValidateAllInboundLinks(Cancel ctx = default) + /// Validate all published cross-links across all published links.json files. + [NoOptionsInjection] + public async Task ValidateAll(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); serviceInvoker.AddCommand(_linkIndexService, static async (s, collector, ctx) => await s.CheckAll(collector, ctx)); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - /// Validate all published cross_links in all published links.json files. - /// - /// - /// - [Command("validate")] - public async Task ValidateRepoInboundLinks(string? from = null, string? to = null, Cancel ctx = default) + /// Validate cross-links for a specific repository. + /// Source repository + /// Target repository + [NoOptionsInjection] + public async Task Validate(string? from = null, string? to = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); serviceInvoker.AddCommand(_linkIndexService, (to, from), static async (s, collector, state, ctx) => await s.CheckRepository(collector, state.to, state.from, ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } /// - /// Validate a locally published links.json file against all published links.json files in the registry + /// Validate a locally published links.json against all published link registries. /// - /// Path to `links.json` defaults to '.artifacts/docs/html/links.json' - /// -p, Defaults to the `{pwd}` folder - /// - [Command("validate-link-reference")] - public async Task ValidateLocalLinkReference(string? file = null, string? path = null, Cancel ctx = default) + /// Path to links.json. Defaults to .artifacts/docs/html/links.json + /// -p, Defaults to the cwd folder + [NoOptionsInjection] + public async Task ValidateLinkReference(string? file = null, string? path = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); serviceInvoker.AddCommand(_linkIndexService, (file, path), static async (s, collector, state, ctx) => await s.CheckWithLocalLinksJson(collector, state.file, state.path, ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index 556489eb0f..353e87f41b 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -2,14 +2,15 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; + +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands; @@ -22,99 +23,36 @@ IEnvironmentVariables environmentVariables ) { /// - /// Index a single documentation set to Elasticsearch, calls `docs-builder --exporters elasticsearch`. Exposes more options + /// Index a single documentation set to Elasticsearch. /// - /// -es, Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL - /// path to the documentation folder, defaults to pwd. - /// Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY - /// Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME - /// Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD - /// Disable AI enrichment of documents using LLM-generated metadata (enabled by default) - /// The number of search threads the inference endpoint should use. Defaults: 8 - /// The number of index threads the inference endpoint should use. Defaults: 8 - /// Do not use the Elastic Inference Service, bootstrap inference endpoint - /// Force reindex strategy to semantic index - /// Timeout in minutes for the inference endpoint creation. Defaults: 4 - /// The number of documents to send to ES as part of the bulk. Defaults: 100 - /// The number of times failed bulk items should be retried. Defaults: 3 - /// Buffer ES request/responses for better error messages and pass ?pretty to all requests - /// Route requests through a proxy server - /// Proxy server password - /// Proxy server username - /// Disable SSL certificate validation (EXPERT OPTION) - /// Pass a self-signed certificate fingerprint to validate the SSL connection - /// Pass a self-signed certificate to validate the SSL connection - /// If the certificate is not root but only part of the validation chain pass this - /// - /// - [Command("")] + /// + /// Calls docs-builder --exporters elasticsearch with full control over all Elasticsearch options. + /// + /// docs-builder index --endpoint https://localhost:9200 --api-key YOUR_KEY + /// docs-builder index --endpoint https://es:9200 --username elastic --password secret + /// + /// + [CommandName("index")] public async Task Index( - string? endpoint = null, + GlobalCliOptions _, + [AsParameters] ElasticsearchIndexOptions es, string? path = null, - string? apiKey = null, - string? username = null, - string? password = null, - - // inference options - bool? noAiEnrichment = null, - int? searchNumThreads = null, - int? indexNumThreads = null, - bool? noEis = null, - int? bootstrapTimeout = null, - - // index options - bool? forceReindex = null, - - // channel buffer options - int? bufferSize = null, - int? maxRetries = null, - - // connection options - bool? debugMode = null, - - // proxy options - string? proxyAddress = null, - string? proxyPassword = null, - string? proxyUsername = null, - - // certificate options - bool? disableSslVerification = null, - string? certificateFingerprint = null, - string? certificatePath = null, - bool? certificateNotRoot = null, - Cancel ctx = default + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealGitRootForPath(path); var service = new IsolatedIndexService(logFactory, configurationContext, githubActionsService, environmentVariables); - var state = (fs, path, - // endpoint options - endpoint, apiKey, username, password, - // inference options - noAiEnrichment, indexNumThreads, noEis, searchNumThreads, bootstrapTimeout, - // channel and connection options - forceReindex, bufferSize, maxRetries, debugMode, - // proxy options - proxyAddress, proxyPassword, proxyUsername, - // certificate options - disableSslVerification, certificateFingerprint, certificatePath, certificateNotRoot - ); + var state = (fs, path, es); serviceInvoker.AddCommand(service, state, static async (s, collector, state, ctx) => await s.Index(collector, state.fs, state.path, - // endpoint options - state.endpoint, state.apiKey, state.username, state.password, - // inference options - state.noAiEnrichment, state.searchNumThreads, state.indexNumThreads, state.noEis, state.bootstrapTimeout, - // channel and connection options - state.forceReindex, state.bufferSize, state.maxRetries, state.debugMode, - // proxy options - state.proxyAddress, state.proxyPassword, state.proxyUsername, - // certificate options - state.disableSslVerification, state.certificateFingerprint, state.certificatePath, state.certificateNotRoot - , ctx) + state.es.Endpoint?.ToString(), state.es.ApiKey, state.es.Username, state.es.Password, + state.es.NoAiEnrichment, state.es.SearchNumThreads, state.es.IndexNumThreads, state.es.NoEis, state.es.BootstrapTimeout, + state.es.ForceReindex, state.es.BufferSize, state.es.MaxRetries, state.es.DebugMode, + state.es.ProxyAddress?.ToString(), state.es.ProxyPassword, state.es.ProxyUsername, + state.es.DisableSslVerification, state.es.CertificateFingerprint, state.es.CertificatePath, state.es.CertificateNotRoot, + ctx) ); - - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index a1474e3505..36a778c6d7 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -4,7 +4,6 @@ using System.IO.Abstractions; using Actions.Core.Services; -using ConsoleAppFramework; using Documentation.Builder.Arguments; using Elastic.Documentation; using Elastic.Documentation.Configuration; @@ -12,6 +11,7 @@ using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands; @@ -25,26 +25,31 @@ IEnvironmentVariables environmentVariables { /// /// Builds a source documentation set folder. - /// global options: - /// --log-level level /// - /// -p, Defaults to the`{pwd}/docs` folder - /// -o, Defaults to `.artifacts/html` - /// Specifies the path prefix for urls - /// Force a full rebuild of the destination folder - /// Treat warnings as errors and fail the build on warnings - /// Allow indexing and following of HTML files - /// Only emit documentation metadata to output, ignored if 'exporters' is also set - /// Set available exporters: - /// html, es, config, links, state, llm, redirect, metadata, none. - /// Defaults to (html, config, links, state, redirect) or 'default'. + /// + /// + /// docs-builder + /// docs-builder -p ./my-docs -o .artifacts/html --strict + /// docs-builder --exporters html,es --canonical-base-url https://elastic.co/docs + /// + /// + /// -p, Defaults to the cwd/docs folder + /// -o, Defaults to .artifacts/html + /// Specifies the path prefix for URLs + /// Force a full rebuild of the destination folder + /// Treat warnings as errors and fail the build on warnings + /// Allow indexing and following of HTML files + /// Only emit documentation metadata to output (ignored when --exporters is also set) + /// + /// Comma-separated exporter list. Values: html, es, config, links, state, llm, redirect, metadata, none, default. + /// Default: (html, config, links, state, redirect). /// - /// The base URL for the canonical url tag - /// Run the build in memory without writing to disk - /// Skip OpenAPI documentation generation for faster builds - /// - [Command("")] + /// The base URL for the canonical URL tag + /// Run the build in memory without writing to disk + /// Skip OpenAPI documentation generation for faster builds + [DefaultCommand] public async Task Build( + GlobalCliOptions _, string? path = null, string? output = null, string? pathPrefix = null, @@ -52,11 +57,11 @@ public async Task Build( bool? strict = null, bool? allowIndexing = null, bool? metadataOnly = null, - [ExporterParser] IReadOnlySet? exporters = null, + [ArgumentParser(typeof(ExporterParser))] IReadOnlySet? exporters = null, string? canonicalBaseUrl = null, bool inMemory = false, bool skipApi = false, - Cancel ctx = default + CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -75,7 +80,6 @@ async static (s, collector, state, ctx) => await s.Build( state.exporters, state.canonicalBaseUrl, state.writeFs, state.skipApi, false, ctx ) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); } - } diff --git a/src/tooling/docs-builder/Commands/MoveCommand.cs b/src/tooling/docs-builder/Commands/MoveCommand.cs index 540e7da3a7..0b29a0c332 100644 --- a/src/tooling/docs-builder/Commands/MoveCommand.cs +++ b/src/tooling/docs-builder/Commands/MoveCommand.cs @@ -3,36 +3,43 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using ConsoleAppFramework; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Refactor; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands; -internal sealed class MoveCommand( +internal sealed class RefactorCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, IConfigurationContext configurationContext ) { /// - /// Move a file from one location to another and update all links in the documentation + /// Move a file or folder and update all links in the documentation. /// + /// + /// + /// docs-builder mv ./docs/old-page.md ./docs/new-page.md + /// docs-builder mv ./docs/old-section ./docs/new-section --dry-run + /// + /// /// The source file or folder path to move from /// The target file or folder path to move to - /// -p, Defaults to the`{pwd}` folder - /// Dry run the move operation - /// - [Command("")] + /// -p, Defaults to the cwd folder + /// Preview the move operation without applying changes + [CommandName("mv")] public async Task Move( + GlobalCliOptions _, [Argument] string source, [Argument] string target, bool? dryRun = null, string? path = null, - Cancel ctx = default + Cancel ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -43,6 +50,45 @@ public async Task Move( serviceInvoker.AddCommand(service, (source, target, dryRun, path, fs), async static (s, collector, state, ctx) => await s.Move(collector, state.source, state.target, state.dryRun, state.path, state.fs, ctx) ); - return await serviceInvoker.InvokeAsync(ctx); + return await serviceInvoker.InvokeAsync(ct); + } + + /// + /// Format documentation files by fixing common issues such as irregular spacing. + /// + /// + /// Exactly one of --check or --write must be specified. + /// + /// docs-builder format --check + /// docs-builder format --write -p ./my-docs + /// + /// + /// -p, Path to the documentation folder. Defaults to cwd + /// Check if files need formatting without modifying them (exits with code 1 if formatting is needed) + /// Write formatting changes to files + [CommandName("format")] + public async Task Format( + GlobalCliOptions _, + string? path = null, + bool check = false, + bool write = false, + Cancel ct = default + ) + { + if (check == write) + { + collector.EmitError(string.Empty, "Must specify exactly one of --check or --write"); + return 1; + } + + await using var serviceInvoker = new ServiceInvoker(collector); + + var service = new FormatService(logFactory, configurationContext); + var fs = FileSystemFactory.RealGitRootForPath(path); + + serviceInvoker.AddCommand(service, (path, check, fs), + async static (s, collector, state, ctx) => await s.Format(collector, state.path, state.check, state.fs, ctx) + ); + return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index 6b3b3ba883..f9dbd3c87b 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -3,10 +3,11 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using ConsoleAppFramework; using Documentation.Builder.Http; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands; @@ -15,24 +16,26 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont private readonly ILogger _logger = logFactory.CreateLogger(); /// - /// Continuously serve a documentation folder at http://localhost:3000. - /// File systems changes will be reflected without having to restart the server. + /// Continuously serve a documentation folder at http://localhost:3000. /// - /// -p, Path to serve the documentation. - /// Defaults to the`{pwd}/docs` folder - /// - /// Port to serve the documentation. - /// special flag for dotnet watch optimizations during development - /// - [Command("")] - public async Task Serve(string? path = null, int port = 3000, bool watch = false, Cancel ctx = default) + /// + /// File system changes are reflected without restarting the server. + /// + /// docs-builder serve + /// docs-builder serve -p ./my-docs --port 8080 + /// + /// + /// -p, Path to serve. Defaults to the cwd/docs folder + /// Port to serve the documentation. Default: 3000 + /// Special flag for dotnet watch optimizations during development + [CommandName("serve")] + public async Task Serve(GlobalCliOptions _, string? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) { var host = new DocumentationWebHost(logFactory, path, port, FileSystemFactory.RealGitRootForPath(path), FileSystemFactory.InMemory(), configurationContext, watch); - await host.RunAsync(ctx); + await host.RunAsync(ct); _logger.LogInformation("Find your documentation at http://localhost:{Port}/{Path}", port, host.GeneratorState.Generator.DocumentationSet.FirstInterestingUrl.TrimStart('/') ); - await host.StopAsync(ctx); + await host.StopAsync(ct); } - } diff --git a/src/tooling/docs-builder/DocumentationTooling.cs b/src/tooling/docs-builder/DocumentationTooling.cs index 0aee7f55d3..cfc9448786 100644 --- a/src/tooling/docs-builder/DocumentationTooling.cs +++ b/src/tooling/docs-builder/DocumentationTooling.cs @@ -39,9 +39,6 @@ public static TBuilder AddDocumentationToolingDefaults(this TBuilder b { var logFactory = sp.GetRequiredService(); var githubActionsService = sp.GetRequiredService(); - var globalArgs = sp.GetRequiredService(); - if (globalArgs.IsHelpOrVersion || globalArgs.IsMcp) - return new DiagnosticsCollector([]); return new ConsoleDiagnosticsCollector(logFactory, githubActionsService); }) .AddSingleton(_ => diff --git a/src/tooling/docs-builder/Filters/InfoLoggerFilter.cs b/src/tooling/docs-builder/Filters/InfoLoggerFilter.cs deleted file mode 100644 index 2a803d94d6..0000000000 --- a/src/tooling/docs-builder/Filters/InfoLoggerFilter.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.Reflection; -using ConsoleAppFramework; -using Elastic.Documentation; -using Elastic.Documentation.Configuration; -using Microsoft.Extensions.Logging; - -namespace Documentation.Builder.Filters; - -internal sealed class InfoLoggerFilter( - ConsoleAppFilter next, - ILogger logger, - ConfigurationFileProvider fileProvider, - GlobalCliArgs cli -) - : ConsoleAppFilter(next) -{ - public override async Task InvokeAsync(ConsoleAppContext context, Cancel cancellationToken) - { - var assemblyVersion = Assembly.GetExecutingAssembly().GetCustomAttributes() - .FirstOrDefault()?.InformationalVersion; - if (cli.IsHelpOrVersion) - { - await Next.InvokeAsync(context, cancellationToken); - return; - } - logger.LogInformation("Configuration source: {ConfigurationSource}", fileProvider.ConfigurationSource.ToStringFast(true)); - if (fileProvider.ConfigurationSource == ConfigurationSource.Remote) - logger.LogInformation("Configuration source git reference: {ConfigurationSourceGitReference}", fileProvider.GitReference); - logger.LogInformation("Version: {Version}", assemblyVersion); - await Next.InvokeAsync(context, cancellationToken); - } -} diff --git a/src/tooling/docs-builder/Filters/ReplaceLogFilter.cs b/src/tooling/docs-builder/Filters/ReplaceLogFilter.cs deleted file mode 100644 index d1522eb683..0000000000 --- a/src/tooling/docs-builder/Filters/ReplaceLogFilter.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.Diagnostics.CodeAnalysis; -using ConsoleAppFramework; -using Elastic.Documentation; -using Microsoft.Extensions.Logging; - -namespace Documentation.Builder.Filters; - -internal sealed class ReplaceLogFilter(ConsoleAppFilter next, ILogger logger, GlobalCliArgs cli) - : ConsoleAppFilter(next) -{ - [SuppressMessage("Usage", "CA2254:Template should be a static expression")] - public override Task InvokeAsync(ConsoleAppContext context, Cancel cancellationToken) - { - if (cli.IsMcp) - { - ConsoleApp.Log = _ => { }; - ConsoleApp.LogError = _ => { }; - } - else if (!cli.IsHelpOrVersion) - { - ConsoleApp.Log = msg => logger.LogInformation(msg); - ConsoleApp.LogError = msg => logger.LogError(msg); - } - - return Next.InvokeAsync(context, cancellationToken); - } -} diff --git a/src/tooling/docs-builder/Filters/StopwatchFilter.cs b/src/tooling/docs-builder/Filters/StopwatchFilter.cs deleted file mode 100644 index ff0bc2fb75..0000000000 --- a/src/tooling/docs-builder/Filters/StopwatchFilter.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.Diagnostics; -using ConsoleAppFramework; -using Microsoft.Extensions.Logging; - -namespace Documentation.Builder.Filters; - -internal sealed class StopwatchFilter(ConsoleAppFilter next, ILogger logger) : ConsoleAppFilter(next) -{ - public override async Task InvokeAsync(ConsoleAppContext context, Cancel cancellationToken) - { - var isHelpOrVersion = context.Arguments.Any(a => a is "--help" or "-h" or "--version"); - var name = string.IsNullOrWhiteSpace(context.CommandName) ? "generate" : context.CommandName; - var startTime = Stopwatch.GetTimestamp(); - if (!isHelpOrVersion) - logger.LogInformation("{Name} :: Starting...", name); - try - { - await Next.InvokeAsync(context, cancellationToken); - } - finally - { - var endTime = Stopwatch.GetElapsedTime(startTime); - if (!isHelpOrVersion) - logger.LogInformation("{Name} :: Finished in '{EndTime}'", name, endTime); - } - } -} diff --git a/src/tooling/docs-builder/GlobalCliOptions.cs b/src/tooling/docs-builder/GlobalCliOptions.cs new file mode 100644 index 0000000000..3b39d216e1 --- /dev/null +++ b/src/tooling/docs-builder/GlobalCliOptions.cs @@ -0,0 +1,23 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation; +using Microsoft.Extensions.Logging; + +namespace Documentation.Builder; + +/// +/// Global CLI options available to every command via argh's first-parameter injection. +/// +public class GlobalCliOptions +{ + /// -l,--log-level, Log verbosity level: trace, debug, information, warning, error, critical. Default: information + public LogLevel LogLevel { get; set; } = LogLevel.Information; + + /// -c,--config-source, Override the configuration source: local, remote + //public ConfigurationSource? ConfigurationSource { get; set; } + + /// Skip cloning private repositories + public bool SkipPrivateRepositories { get; set; } +} diff --git a/src/tooling/docs-builder/Filters/CatchExceptionFilter.cs b/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs similarity index 52% rename from src/tooling/docs-builder/Filters/CatchExceptionFilter.cs rename to src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs index ce0ceccfc7..0c2d077a05 100644 --- a/src/tooling/docs-builder/Filters/CatchExceptionFilter.cs +++ b/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs @@ -2,18 +2,18 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using ConsoleAppFramework; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; +using Nullean.Argh.Middleware; -namespace Documentation.Builder.Filters; +namespace Documentation.Builder.Middleware; - -internal sealed class CatchExceptionFilter(ConsoleAppFilter next, ILogger logger, IDiagnosticsCollector collector) - : ConsoleAppFilter(next) +internal sealed class CatchExceptionMiddleware(ILogger logger, IDiagnosticsCollector collector) + : ICommandMiddleware { private bool _cancelKeyPressed; - public override async Task InvokeAsync(ConsoleAppContext context, Cancel cancellationToken) + + public async ValueTask InvokeAsync(CommandContext context, CommandMiddlewareDelegate next) { Console.CancelKeyPress += (_, _) => { @@ -22,19 +22,19 @@ public override async Task InvokeAsync(ConsoleAppContext context, Cancel cancell }; try { - await Next.InvokeAsync(context, cancellationToken); + await next(context); } catch (Exception ex) { - if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested && _cancelKeyPressed) + if (ex is OperationCanceledException && context.CancellationToken.IsCancellationRequested && _cancelKeyPressed) { logger.LogInformation("Cancellation requested, exiting."); return; } - _ = collector.StartAsync(cancellationToken); + _ = collector.StartAsync(context.CancellationToken); collector.EmitGlobalError($"Global unhandled exception: {ex.Message}", ex); - await collector.StopAsync(cancellationToken); - Environment.ExitCode = 1; + await collector.StopAsync(context.CancellationToken); + context.ExitCode = 1; } } } diff --git a/src/tooling/docs-builder/Filters/CheckForUpdatesFilter.cs b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs similarity index 51% rename from src/tooling/docs-builder/Filters/CheckForUpdatesFilter.cs rename to src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs index 23eb693ad2..1ffe552596 100644 --- a/src/tooling/docs-builder/Filters/CheckForUpdatesFilter.cs +++ b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs @@ -4,69 +4,68 @@ using System.IO.Abstractions; using System.Reflection; -using ConsoleAppFramework; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Microsoft.Extensions.Logging; +using Nullean.Argh.Middleware; -namespace Documentation.Builder.Filters; +namespace Documentation.Builder.Middleware; -internal sealed class CheckForUpdatesFilter(ConsoleAppFilter next, GlobalCliArgs cli) : ConsoleAppFilter(next) +internal sealed class CheckForUpdatesMiddleware(ILogger logger) : ICommandMiddleware { // Only accesses ApplicationData — no workspace access needed private static readonly IFileSystem Fs = FileSystemFactory.AppData; private readonly IFileInfo _stateFile = Fs.FileInfo.New(Path.Join(Paths.ApplicationData.FullName, "docs-build-check.state")); + private readonly ILogger _logger = logger; - public override async Task InvokeAsync(ConsoleAppContext context, Cancel ctx) + public async ValueTask InvokeAsync(CommandContext context, CommandMiddlewareDelegate next) { - await Next.InvokeAsync(context, ctx); + await next(context); if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) return; - if (cli.IsHelpOrVersion || cli.IsMcp) - return; - var latestVersionUrl = await GetLatestVersion(ctx); + var latestVersionUrl = await GetLatestVersion(context.CancellationToken); if (latestVersionUrl is null) - ConsoleApp.LogError("Unable to determine latest version"); + _logger.LogWarning("Unable to determine latest version"); else CompareWithAssemblyVersion(latestVersionUrl); } - private static void CompareWithAssemblyVersion(Uri latestVersionUrl) + private void CompareWithAssemblyVersion(Uri latestVersionUrl) { var versionPath = latestVersionUrl.AbsolutePath.Split('/').Last(); if (!SemVersion.TryParse(versionPath, out var latestVersion)) { - ConsoleApp.LogError($"Unable to parse latest version from {latestVersionUrl}"); + _logger.LogWarning("Unable to parse latest version from {LatestVersionUrl}", latestVersionUrl); return; } - var assemblyVersion = Assembly.GetExecutingAssembly().GetCustomAttributes() + var assemblyVersion = Assembly.GetExecutingAssembly() + .GetCustomAttributes() .FirstOrDefault()?.InformationalVersion; - if (SemVersion.TryParse(assemblyVersion ?? "", out var currentSemVersion)) + + if (!SemVersion.TryParse(assemblyVersion ?? "", out var currentSemVersion)) { - var currentVersion = new SemVersion(currentSemVersion.Major, currentSemVersion.Minor, currentSemVersion.Patch); - if (latestVersion <= currentVersion) - return; - ConsoleApp.Log(""); - ConsoleApp.Log($"A new version of docs-builder is available: {latestVersion} currently on version {currentSemVersion}"); - ConsoleApp.Log(""); - ConsoleApp.Log($" {latestVersionUrl}"); - ConsoleApp.Log(""); - ConsoleApp.Log("Read more about updating here:"); - ConsoleApp.Log(" https://elastic.github.io/docs-builder/contribute/locally#step-one "); - ConsoleApp.Log(""); + _logger.LogWarning("Unable to parse current version from docs-builder binary"); return; } - ConsoleApp.LogError($"Unable to parse current version from docs-builder binary"); + var currentVersion = new SemVersion(currentSemVersion.Major, currentSemVersion.Minor, currentSemVersion.Patch); + if (latestVersion <= currentVersion) + return; + + _logger.LogInformation(""); + _logger.LogInformation("A new version of docs-builder is available: {Latest} (currently on {Current})", latestVersion, currentSemVersion); + _logger.LogInformation(" {LatestVersionUrl}", latestVersionUrl); + _logger.LogInformation("Read more about updating: https://elastic.github.io/docs-builder/contribute/locally#step-one"); } - private async ValueTask GetLatestVersion(Cancel ctx) + private async ValueTask GetLatestVersion(CancellationToken ct) { // only check for new versions once per hour if (_stateFile.Exists && _stateFile.LastWriteTimeUtc >= DateTime.UtcNow.Subtract(TimeSpan.FromHours(1))) { - var url = await Fs.File.ReadAllTextAsync(_stateFile.FullName, ctx); + var url = await Fs.File.ReadAllTextAsync(_stateFile.FullName, ct); if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri; } @@ -74,19 +73,16 @@ private static void CompareWithAssemblyVersion(Uri latestVersionUrl) try { var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); - var response = await httpClient.GetAsync("https://github.com/elastic/docs-builder/releases/latest", ctx); + var response = await httpClient.GetAsync("https://github.com/elastic/docs-builder/releases/latest", ct); var redirectUrl = response.Headers.Location; if (redirectUrl is not null && _stateFile.Directory is not null) { - // ensure the 'elastic' folder exists. if (!Fs.Directory.Exists(_stateFile.Directory.FullName)) _ = Fs.Directory.CreateDirectory(_stateFile.Directory.FullName); - await Fs.File.WriteAllTextAsync(_stateFile.FullName, redirectUrl.ToString(), ctx); + await Fs.File.WriteAllTextAsync(_stateFile.FullName, redirectUrl.ToString(), ct); } return redirectUrl; } - // ReSharper disable once RedundantEmptyFinallyBlock - // ignore on purpose finally { } } } diff --git a/src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs b/src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs new file mode 100644 index 0000000000..8121f4ef08 --- /dev/null +++ b/src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs @@ -0,0 +1,28 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Reflection; +using Elastic.Documentation.Configuration; +using Microsoft.Extensions.Logging; +using Nullean.Argh.Middleware; + +namespace Documentation.Builder.Middleware; + +internal sealed class InfoLoggerMiddleware(ILogger logger, ConfigurationFileProvider fileProvider) + : ICommandMiddleware +{ + public async ValueTask InvokeAsync(CommandContext context, CommandMiddlewareDelegate next) + { + var assemblyVersion = Assembly.GetExecutingAssembly() + .GetCustomAttributes() + .FirstOrDefault()?.InformationalVersion; + + logger.LogInformation("Configuration source: {ConfigurationSource}", fileProvider.ConfigurationSource); + if (fileProvider.ConfigurationSource == Elastic.Documentation.ConfigurationSource.Remote) + logger.LogInformation("Configuration source git reference: {ConfigurationSourceGitReference}", fileProvider.GitReference); + logger.LogInformation("Version: {Version}", assemblyVersion); + + await next(context); + } +} diff --git a/src/tooling/docs-builder/Middleware/StopwatchMiddleware.cs b/src/tooling/docs-builder/Middleware/StopwatchMiddleware.cs new file mode 100644 index 0000000000..03955bf9a8 --- /dev/null +++ b/src/tooling/docs-builder/Middleware/StopwatchMiddleware.cs @@ -0,0 +1,28 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Nullean.Argh.Middleware; + +namespace Documentation.Builder.Middleware; + +internal sealed class StopwatchMiddleware(ILogger logger) : ICommandMiddleware +{ + public async ValueTask InvokeAsync(CommandContext context, CommandMiddlewareDelegate next) + { + var name = context.CommandName.Length == 0 ? "generate" : context.CommandName; + var startTime = Stopwatch.GetTimestamp(); + logger.LogInformation("{Name} :: Starting...", name); + try + { + await next(context); + } + finally + { + var elapsed = Stopwatch.GetElapsedTime(startTime); + logger.LogInformation("{Name} :: Finished in '{Elapsed}'", name, elapsed); + } + } +} diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index 0829f83e42..f2c15b146e 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -2,57 +2,66 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using ConsoleAppFramework; using Documentation.Builder; using Documentation.Builder.Commands; using Documentation.Builder.Commands.Assembler; using Documentation.Builder.Commands.Codex; -using Documentation.Builder.Filters; +using Documentation.Builder.Middleware; +using Elastic.Documentation; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.ServiceDefaults; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Nullean.Argh.Hosting; var builder = Host.CreateApplicationBuilder() - .AddDocumentationServiceDefaults(ref args, (s, p) => + .AddDocumentationServiceDefaults(args, (s, p) => { _ = s.AddSingleton(AssemblyConfiguration.Create(p)); }) .AddDocumentationToolingDefaults() .AddOpenTelemetryDefaults(); -var app = builder.ToConsoleAppBuilder(); - -app.UseFilter(); -app.UseFilter(); -app.UseFilter(); -app.UseFilter(); -app.UseFilter(); - -app.Add(); -app.Add("inbound-links"); -app.Add("diff"); -app.Add("mv"); -app.Add("serve"); -app.Add("index"); -app.Add("format"); -app.Add("changelog"); - -//assembler commands - -app.Add("assembler content-source"); -app.Add("assembler deploy"); -app.Add("assembler bloom-filter"); -app.Add("assembler navigation"); -app.Add("assembler config"); -app.Add("assembler index"); -app.Add("assembler sitemap"); -app.Add("assembler"); -app.Add("assemble"); - -//codex commands -app.Add("codex update-redirects"); -app.Add("codex index"); -app.Add("codex"); - -await app.RunAsync(args).ConfigureAwait(false); +_ = builder.Services.AddArgh(args, app => +{ + _ = app.UseGlobalOptions(); + + _ = app.UseMiddleware(); + _ = app.UseMiddleware(); + _ = app.UseMiddleware(); + _ = app.UseMiddleware(); + + // Root default: `docs-builder` with no sub-command → build docs from cwd + _ = app.Map(); + + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.MapNamespace("changelog"); + _ = app.MapNamespace("inbound-links"); + + _ = app.Map(); + + // assembler commands (assemble merged into assembler default) + _ = app.MapNamespace("assembler", g => + { + _ = g.MapNamespace("content-source"); + _ = g.MapNamespace("deploy"); + _ = g.MapNamespace("bloom-filter"); + _ = g.MapNamespace("navigation"); + _ = g.MapNamespace("config"); + _ = g.Map(); + _ = g.Map(); + }); + + // codex commands + _ = app.MapNamespace("codex", g => + { + _ = g.Map(); + _ = g.Map(); + }); +}); + +using var host = builder.Build(); +await host.RunAsync(); diff --git a/src/tooling/docs-builder/docs-builder.csproj b/src/tooling/docs-builder/docs-builder.csproj index c7281c623c..40d37cb902 100644 --- a/src/tooling/docs-builder/docs-builder.csproj +++ b/src/tooling/docs-builder/docs-builder.csproj @@ -22,10 +22,7 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + From 2ce4dac06af4ae169ab374be94c60afd8c3ce8dd Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 24 Apr 2026 21:38:52 +0200 Subject: [PATCH 02/27] refactor(services): introduce DTOs to replace flat parameter lists Create IsolatedBuildOptions, AssemblerBuildOptions, and AssemblerCloneOptions records in the service layer. Update all six affected service methods to accept these DTOs and the existing ElasticsearchIndexOptions directly, eliminating the tuple-based state unpacking in ServiceInvoker lambdas. Commands are now thin wrappers that construct a DTO and pass it straight through. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Building/AssemblerBuildOptions.cs | 16 +++ .../Building/AssemblerBuildService.cs | 13 ++- .../Building/AssemblerSitemapService.cs | 30 +----- .../Indexing/AssemblerIndexService.cs | 94 +++--------------- .../Sourcing/AssemblerCloneOptions.cs | 14 +++ .../Sourcing/AssemblerCloneService.cs | 10 +- .../IsolatedBuildOptions.cs | 21 ++++ .../IsolatedBuildService.cs | 24 ++--- .../IsolatedIndexService.cs | 98 +++---------------- .../Commands/Assembler/AssemblerCommands.cs | 45 ++++++--- .../Assembler/AssemblerIndexCommand.cs | 12 +-- .../Assembler/AssemblerSitemapCommand.cs | 10 +- .../docs-builder/Commands/IndexCommand.cs | 12 +-- .../Commands/IsolatedBuildCommand.cs | 17 ++-- .../docs-builder/Http/InMemoryBuildState.cs | 22 ++--- 15 files changed, 162 insertions(+), 276 deletions(-) create mode 100644 src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs create mode 100644 src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneOptions.cs create mode 100644 src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs new file mode 100644 index 0000000000..cbd6108b5f --- /dev/null +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs @@ -0,0 +1,16 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Assembler.Building; + +/// Options for an assembler build, bound from CLI flags via argh [AsParameters]. +public record AssemblerBuildOptions +{ + public bool? Strict { get; init; } + public string? Environment { get; init; } + public bool? MetadataOnly { get; init; } + public bool? ShowHints { get; init; } + public IReadOnlySet? Exporters { get; init; } + public bool? AssumeBuild { get; init; } +} diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 1c4639db9c..bc9615e18d 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -32,16 +32,19 @@ IEnvironmentVariables environmentVariables public async Task BuildAll( IDiagnosticsCollector collector, - bool? strict, string? environment, - bool? metadataOnly, - bool? showHints, - IReadOnlySet? exporters, - bool? assumeBuild, + AssemblerBuildOptions options, ScopedFileSystem readFs, ScopedFileSystem writeFs, Cancel ctx ) { + var strict = options.Strict; + var environment = options.Environment; + var metadataOnly = options.MetadataOnly; + var showHints = options.ShowHints; + var exporters = options.Exporters; + var assumeBuild = options.AssumeBuild; + collector.NoHints = !showHints.GetValueOrDefault(false); strict ??= false; exporters ??= metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs index 705abdd623..db4faf9d43 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs @@ -27,19 +27,8 @@ ICoreService githubActionsService public async Task GenerateSitemapAsync( IDiagnosticsCollector collector, ScopedFileSystem fileSystem, - string? endpoint = null, + ElasticsearchIndexOptions es, string? environment = null, - string? apiKey = null, - string? username = null, - string? password = null, - bool? debugMode = null, - string? proxyAddress = null, - string? proxyPassword = null, - string? proxyUsername = null, - bool? disableSslVerification = null, - string? certificateFingerprint = null, - string? certificatePath = null, - bool? certificateNotRoot = null, Cancel ctx = default ) { @@ -54,22 +43,7 @@ public async Task GenerateSitemapAsync( ); var cfg = configurationContext.Endpoints.Elasticsearch; - var options = new ElasticsearchIndexOptions - { - Endpoint = endpoint, - ApiKey = apiKey, - Username = username, - Password = password, - DebugMode = debugMode, - ProxyAddress = proxyAddress, - ProxyPassword = proxyPassword, - ProxyUsername = proxyUsername, - DisableSslVerification = disableSslVerification, - CertificateFingerprint = certificateFingerprint, - CertificatePath = certificatePath, - CertificateNotRoot = certificateNotRoot - }; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, options, collector, fileSystem, ctx); + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); if (collector.Errors > 0) return false; diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs index 7a3953704d..02af5d5e7d 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs @@ -24,93 +24,27 @@ IEnvironmentVariables environmentVariables { private readonly IConfigurationContext _configurationContext = configurationContext; - /// - /// Index documentation to Elasticsearch, calls `docs-builder assembler build --exporters elasticsearch`. Exposes more options - /// - /// - /// - /// Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL - /// The --environment used to clone ends up being part of the index name - /// Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY - /// Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME - /// Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD - /// Disable AI enrichment of documents using LLM-generated metadata (enabled by default) - /// The number of search threads the inference endpoint should use. Defaults: 8 - /// The number of index threads the inference endpoint should use. Defaults: 8 - /// Do not use the Elastic Inference Service, bootstrap inference endpoint - /// Timeout in minutes for the inference endpoint creation. Defaults: 4 - /// Force reindex strategy to semantic index - /// The number of documents to send to ES as part of the bulk. Defaults: 100 - /// The number of times failed bulk items should be retried. Defaults: 3 - /// Buffer ES request/responses for better error messages and pass ?pretty to all requests - /// Route requests through a proxy server - /// Proxy server password - /// Proxy server username - /// Disable SSL certificate validation (EXPERT OPTION) - /// Pass a self-signed certificate fingerprint to validate the SSL connection - /// Pass a self-signed certificate to validate the SSL connection - /// If the certificate is not root but only part of the validation chain pass this - /// - /// - public async Task Index(IDiagnosticsCollector collector, + /// Index assembled documentation to Elasticsearch. + public async Task Index( + IDiagnosticsCollector collector, ScopedFileSystem readFs, ScopedFileSystem writeFs, - string? endpoint = null, + ElasticsearchIndexOptions es, string? environment = null, - string? apiKey = null, - string? username = null, - string? password = null, - // inference options - bool? noAiEnrichment = null, - int? searchNumThreads = null, - int? indexNumThreads = null, - bool? noEis = null, - int? bootstrapTimeout = null, - // index options - bool? forceReindex = null, - // channel buffer options - int? bufferSize = null, - int? maxRetries = null, - // connection options - bool? debugMode = null, - string? proxyAddress = null, - string? proxyPassword = null, - string? proxyUsername = null, - bool? disableSslVerification = null, - string? certificateFingerprint = null, - string? certificatePath = null, - bool? certificateNotRoot = null, Cancel ctx = default ) { var cfg = _configurationContext.Endpoints.Elasticsearch; - var options = new ElasticsearchIndexOptions - { - Endpoint = endpoint, - ApiKey = apiKey, - Username = username, - Password = password, - NoAiEnrichment = noAiEnrichment, - SearchNumThreads = searchNumThreads, - IndexNumThreads = indexNumThreads, - NoEis = noEis, - BootstrapTimeout = bootstrapTimeout, - ForceReindex = forceReindex, - BufferSize = bufferSize, - MaxRetries = maxRetries, - DebugMode = debugMode, - ProxyAddress = proxyAddress, - ProxyPassword = proxyPassword, - ProxyUsername = proxyUsername, - DisableSslVerification = disableSslVerification, - CertificateFingerprint = certificateFingerprint, - CertificatePath = certificatePath, - CertificateNotRoot = certificateNotRoot - }; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, options, collector, readFs, ctx); - - var exporters = new HashSet { Elasticsearch }; + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, readFs, ctx); - return await BuildAll(collector, strict: false, environment, metadataOnly: true, showHints: false, exporters, assumeBuild: false, readFs, writeFs, ctx); + return await BuildAll(collector, new AssemblerBuildOptions + { + Strict = false, + Environment = environment, + MetadataOnly = true, + ShowHints = false, + Exporters = new HashSet { Elasticsearch }, + AssumeBuild = false + }, readFs, writeFs, ctx); } } diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneOptions.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneOptions.cs new file mode 100644 index 0000000000..e5447765da --- /dev/null +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneOptions.cs @@ -0,0 +1,14 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Assembler.Sourcing; + +/// Options for cloning assembler repositories, bound from CLI flags via argh [AsParameters]. +public record AssemblerCloneOptions +{ + public bool? Strict { get; init; } + public string? Environment { get; init; } + public bool? FetchLatest { get; init; } + public bool? AssumeCloned { get; init; } +} diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs index f819ebe18a..f3522d25c1 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs @@ -19,18 +19,18 @@ public class AssemblerCloneService( ICoreService githubActionsService ) : IService { - public async Task CloneAll(IDiagnosticsCollector collector, bool? strict, string? environment, bool? fetchLatest, bool? assumeCloned, Cancel ctx) + public async Task CloneAll(IDiagnosticsCollector collector, AssemblerCloneOptions options, Cancel ctx) { - strict ??= false; + var strict = options.Strict ?? false; var githubEnvironmentInput = githubActionsService.GetInput("environment"); - environment ??= !string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"; + var environment = options.Environment ?? (!string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"); var fs = FileSystemFactory.RealRead; var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs, fs, null, null); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); - _ = await cloner.CloneAll(fetchLatest ?? false, assumeCloned ?? false, ctx); + _ = await cloner.CloneAll(options.FetchLatest ?? false, options.AssumeCloned ?? false, ctx); - return strict.Value ? collector.Errors + collector.Warnings == 0 : collector.Errors == 0; + return strict ? collector.Errors + collector.Warnings == 0 : collector.Errors == 0; } } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs new file mode 100644 index 0000000000..283a3cd4e0 --- /dev/null +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -0,0 +1,21 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Isolated; + +/// Options for an isolated documentation set build, bound from CLI flags via argh [AsParameters]. +public record IsolatedBuildOptions +{ + public string? Path { get; init; } + public string? Output { get; init; } + public string? PathPrefix { get; init; } + public bool? Force { get; init; } + public bool? Strict { get; init; } + public bool? AllowIndexing { get; init; } + public bool? MetadataOnly { get; init; } + public IReadOnlySet? Exporters { get; init; } + public string? CanonicalBaseUrl { get; init; } + public bool SkipOpenApi { get; init; } + public bool SkipCrossLinks { get; init; } +} diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 180e315b37..4f6b466060 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -45,21 +45,23 @@ public bool IsStrict(bool? strict) public async Task Build( IDiagnosticsCollector collector, ScopedFileSystem fileSystem, - string? path = null, - string? output = null, - string? pathPrefix = null, - bool? force = null, - bool? strict = null, - bool? allowIndexing = null, - bool? metadataOnly = null, - IReadOnlySet? exporters = null, - string? canonicalBaseUrl = null, + IsolatedBuildOptions options, ScopedFileSystem? writeFileSystem = null, - bool skipOpenApi = false, - bool skipCrossLinks = false, Cancel ctx = default ) { + var path = options.Path; + var output = options.Output; + var pathPrefix = options.PathPrefix; + var force = options.Force; + var strict = options.Strict; + var allowIndexing = options.AllowIndexing; + var metadataOnly = options.MetadataOnly; + var exporters = options.Exporters; + var canonicalBaseUrl = options.CanonicalBaseUrl; + var skipOpenApi = options.SkipOpenApi; + var skipCrossLinks = options.SkipCrossLinks; + strict = IsStrict(strict); if (bool.TryParse(githubActionsService.GetInput("metadata-only"), out var metaValue) && metaValue) diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs index b20980e233..6ab67a0ae6 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs @@ -21,96 +21,26 @@ IEnvironmentVariables environmentVariables { private readonly IConfigurationContext _configurationContext = configurationContext; - /// - /// Index documentation to Elasticsearch, calls `docs-builder assembler build --exporters elasticsearch`. Exposes more options - /// - /// - /// - /// path to the documentation folder, defaults to pwd. - /// Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL - /// Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY - /// Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME - /// Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD - /// Disable AI enrichment of documents using LLM-generated metadata (enabled by default) - /// The number of search threads the inference endpoint should use. Defaults: 8 - /// The number of index threads the inference endpoint should use. Defaults: 8 - /// Do not use the Elastic Inference Service, bootstrap inference endpoint - /// Timeout in minutes for the inference endpoint creation. Defaults: 4 - /// Force reindex strategy to semantic index - /// The number of documents to send to ES as part of the bulk. Defaults: 100 - /// The number of times failed bulk items should be retried. Defaults: 3 - /// Buffer ES request/responses for better error messages and pass ?pretty to all requests - /// Route requests through a proxy server - /// Proxy server password - /// Proxy server username - /// Disable SSL certificate validation (EXPERT OPTION) - /// Pass a self-signed certificate fingerprint to validate the SSL connection - /// Pass a self-signed certificate to validate the SSL connection - /// If the certificate is not root but only part of the validation chain pass this - /// - /// - public async Task Index(IDiagnosticsCollector collector, + /// Index a single documentation set to Elasticsearch. + public async Task Index( + IDiagnosticsCollector collector, ScopedFileSystem fileSystem, + ElasticsearchIndexOptions es, string? path = null, - string? endpoint = null, - string? apiKey = null, - string? username = null, - string? password = null, - // inference options - bool? noAiEnrichment = null, - int? searchNumThreads = null, - int? indexNumThreads = null, - bool? noEis = null, - int? bootstrapTimeout = null, - // index options - bool? forceReindex = null, - // channel buffer options - int? bufferSize = null, - int? maxRetries = null, - // connection options - bool? debugMode = null, - string? proxyAddress = null, - string? proxyPassword = null, - string? proxyUsername = null, - bool? disableSslVerification = null, - string? certificateFingerprint = null, - string? certificatePath = null, - bool? certificateNotRoot = null, Cancel ctx = default ) { var cfg = _configurationContext.Endpoints.Elasticsearch; - var options = new ElasticsearchIndexOptions - { - Endpoint = endpoint, - ApiKey = apiKey, - Username = username, - Password = password, - NoAiEnrichment = noAiEnrichment, - SearchNumThreads = searchNumThreads, - IndexNumThreads = indexNumThreads, - NoEis = noEis, - BootstrapTimeout = bootstrapTimeout, - ForceReindex = forceReindex, - BufferSize = bufferSize, - MaxRetries = maxRetries, - DebugMode = debugMode, - ProxyAddress = proxyAddress, - ProxyPassword = proxyPassword, - ProxyUsername = proxyUsername, - DisableSslVerification = disableSslVerification, - CertificateFingerprint = certificateFingerprint, - CertificatePath = certificatePath, - CertificateNotRoot = certificateNotRoot - }; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, options, collector, fileSystem, ctx); - - var exporters = new HashSet { Elasticsearch }; + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); - return await Build(collector, fileSystem, - metadataOnly: true, strict: false, path: path, output: null, pathPrefix: null, - force: true, allowIndexing: null, exporters: exporters, canonicalBaseUrl: null, - skipOpenApi: true, - ctx: ctx); + return await Build(collector, fileSystem, new IsolatedBuildOptions + { + Path = path, + MetadataOnly = true, + Strict = false, + Force = true, + SkipOpenApi = true, + Exporters = new HashSet { Elasticsearch } + }, ctx: ctx); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index eaaa3d5a20..23e2ae1e1c 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -8,7 +8,6 @@ using Documentation.Builder.Http; using Elastic.Documentation; using Elastic.Documentation.Assembler.Building; -using Elastic.Documentation.Assembler.Configuration; using Elastic.Documentation.Assembler.Sourcing; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -74,17 +73,26 @@ public async Task Assemble( { await using var serviceInvoker = new ServiceInvoker(collector); + var cloneOptions = new AssemblerCloneOptions + { + Strict = strict, Environment = environment, + FetchLatest = fetchLatest, AssumeCloned = assumeCloned + }; var cloneService = new AssemblerCloneService(logFactory, assemblyConfiguration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(cloneService, (strict, environment, fetchLatest, assumeCloned), strict ?? false, - static async (s, collector, state, ctx) => await s.CloneAll(collector, state.strict, state.environment, state.fetchLatest, state.assumeCloned, ctx) + serviceInvoker.AddCommand(cloneService, cloneOptions, strict ?? false, + static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) ); - var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); + var buildOptions = new AssemblerBuildOptions + { + Strict = strict, Environment = environment, MetadataOnly = metadataOnly, + ShowHints = showHints, Exporters = exporters, AssumeBuild = assumeBuild + }; var readFs = FileSystemFactory.RealRead; var writeFs = FileSystemFactory.RealWrite; - serviceInvoker.AddCommand(buildService, (strict, environment, metadataOnly, showHints, exporters, assumeBuild, readFs, writeFs), strict ?? false, - static async (s, collector, state, ctx) => - await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.readFs, state.writeFs, ctx) + var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); + serviceInvoker.AddCommand(buildService, (buildOptions, readFs, writeFs), strict ?? false, + static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.readFs, state.writeFs, ctx) ); var result = await serviceInvoker.InvokeAsync(ct); @@ -123,12 +131,15 @@ public async Task Clone( ) { await using var serviceInvoker = new ServiceInvoker(collector); - + var options = new AssemblerCloneOptions + { + Strict = strict, Environment = environment, + FetchLatest = fetchLatest, AssumeCloned = assumeCloned + }; var service = new AssemblerCloneService(logFactory, assemblyConfiguration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(service, (strict, environment, fetchLatest, assumeCloned), strict ?? false, - static async (s, collector, state, ctx) => await s.CloneAll(collector, state.strict, state.environment, state.fetchLatest, state.assumeCloned, ctx) + serviceInvoker.AddCommand(service, options, strict ?? false, + static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) ); - return await serviceInvoker.InvokeAsync(ct); } @@ -154,15 +165,17 @@ public async Task Build( ) { await using var serviceInvoker = new ServiceInvoker(collector); - + var options = new AssemblerBuildOptions + { + Strict = strict, Environment = environment, AssumeBuild = assumeBuild, + MetadataOnly = metadataOnly, ShowHints = showHints, Exporters = exporters + }; var readFs = FileSystemFactory.RealRead; var writeFs = FileSystemFactory.RealWrite; var service = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, (strict, environment, assumeBuild, metadataOnly, showHints, exporters, readFs, writeFs), strict ?? false, - static async (s, collector, state, ctx) => - await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.readFs, state.writeFs, ctx) + serviceInvoker.AddCommand(service, (options, readFs, writeFs), strict ?? false, + static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.readFs, state.writeFs, ctx) ); - return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 7c87ae9350..9f67307170 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -4,7 +4,6 @@ using System.IO.Abstractions; using Actions.Core.Services; - using Elastic.Documentation; using Elastic.Documentation.Assembler.Indexing; using Elastic.Documentation.Configuration; @@ -47,15 +46,8 @@ public async Task Index( var readFs = FileSystemFactory.RealRead; var writeFs = FileSystemFactory.RealWrite; var service = new AssemblerIndexService(logFactory, configuration, configurationContext, githubActionsService, environmentVariables); - var state = (readFs, writeFs, environment, es); - serviceInvoker.AddCommand(service, state, - static async (s, collector, state, ctx) => await s.Index(collector, state.readFs, state.writeFs, - state.es.Endpoint?.ToString(), state.environment, state.es.ApiKey, state.es.Username, state.es.Password, - state.es.NoAiEnrichment, state.es.SearchNumThreads, state.es.IndexNumThreads, state.es.NoEis, state.es.BootstrapTimeout, - state.es.ForceReindex, state.es.BufferSize, state.es.MaxRetries, state.es.DebugMode, - state.es.ProxyAddress?.ToString(), state.es.ProxyPassword, state.es.ProxyUsername, - state.es.DisableSslVerification, state.es.CertificateFingerprint, state.es.CertificatePath, state.es.CertificateNotRoot, - ctx) + serviceInvoker.AddCommand(service, + async (s, col, ctx) => await s.Index(col, readFs, writeFs, es, environment, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index 42812baef5..9b4d46ec13 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -4,7 +4,6 @@ using System.IO.Abstractions; using Actions.Core.Services; - using Elastic.Documentation; using Elastic.Documentation.Assembler.Building; using Elastic.Documentation.Configuration; @@ -44,13 +43,8 @@ public async Task Sitemap( await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealWrite; var service = new AssemblerSitemapService(logFactory, configuration, configurationContext, githubActionsService); - var state = (fs, environment, es); - serviceInvoker.AddCommand(service, state, - static async (s, col, state, ctx) => await s.GenerateSitemapAsync(col, state.fs, - state.es.Endpoint?.ToString(), state.environment, state.es.ApiKey, state.es.Username, state.es.Password, - state.es.DebugMode, state.es.ProxyAddress?.ToString(), state.es.ProxyPassword, state.es.ProxyUsername, - state.es.DisableSslVerification, state.es.CertificateFingerprint, state.es.CertificatePath, state.es.CertificateNotRoot, - ctx) + serviceInvoker.AddCommand(service, + async (s, col, ctx) => await s.GenerateSitemapAsync(col, fs, es, environment, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index 353e87f41b..ed287150f1 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using Actions.Core.Services; - using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; @@ -43,15 +42,8 @@ public async Task Index( await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealGitRootForPath(path); var service = new IsolatedIndexService(logFactory, configurationContext, githubActionsService, environmentVariables); - var state = (fs, path, es); - serviceInvoker.AddCommand(service, state, - static async (s, collector, state, ctx) => await s.Index(collector, state.fs, state.path, - state.es.Endpoint?.ToString(), state.es.ApiKey, state.es.Username, state.es.Password, - state.es.NoAiEnrichment, state.es.SearchNumThreads, state.es.IndexNumThreads, state.es.NoEis, state.es.BootstrapTimeout, - state.es.ForceReindex, state.es.BufferSize, state.es.MaxRetries, state.es.DebugMode, - state.es.ProxyAddress?.ToString(), state.es.ProxyPassword, state.es.ProxyUsername, - state.es.DisableSslVerification, state.es.CertificateFingerprint, state.es.CertificatePath, state.es.CertificateNotRoot, - ctx) + serviceInvoker.AddCommand(service, + async (s, col, ctx) => await s.Index(col, fs, es, path, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index 36a778c6d7..82c9ffaa3f 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -68,17 +68,18 @@ public async Task Build( var service = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(path); - // For real builds supply an explicit write FS without .git access; for in-memory null falls back to readFs var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(path, output); + var options = new IsolatedBuildOptions + { + Path = path, Output = output, PathPrefix = pathPrefix, + Force = force, Strict = strict, AllowIndexing = allowIndexing, + MetadataOnly = metadataOnly, Exporters = exporters, + CanonicalBaseUrl = canonicalBaseUrl, SkipOpenApi = skipApi + }; var strictCommand = service.IsStrict(strict); - serviceInvoker.AddCommand(service, - (path, output, pathPrefix, force, strict, allowIndexing, metadataOnly, exporters, canonicalBaseUrl, readFs, writeFs, skipApi), strictCommand, - async static (s, collector, state, ctx) => await s.Build( - collector, state.readFs, state.path, state.output, state.pathPrefix, - state.force, state.strict, state.allowIndexing, state.metadataOnly, - state.exporters, state.canonicalBaseUrl, state.writeFs, state.skipApi, false, ctx - ) + serviceInvoker.AddCommand(service, (options, readFs, writeFs), strictCommand, + static async (s, col, state, ctx) => await s.Build(col, state.readFs, state.options, state.writeFs, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index 43154396c9..35dd2c48b6 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -177,18 +177,18 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) _ = await service.Build( streamingCollector, readFs, - sourcePath, - null, // output - null, // pathPrefix - true, // force - always rebuild for validation - false, // strict - false, // allowIndexing - false, // metadataOnly - ExportOptions.Default, - null, // canonicalBaseUrl + new IsolatedBuildOptions + { + Path = sourcePath, + Force = true, + Strict = false, + AllowIndexing = false, + MetadataOnly = false, + Exporters = ExportOptions.Default, + SkipOpenApi = true, + SkipCrossLinks = false + }, _writeFs, // reuse MockFileSystem across builds for caching - true, // skipOpenApi - skip for faster validation builds - false, // skipCrossLinks - enable cross-links (cached in MockFileSystem) ct ); From acf6d0a8133de5b15a518555dd69b5cfa27d3ae3 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 09:35:19 +0200 Subject: [PATCH 03/27] feat(tooling): upgrade to Nullean.Argh 0.8.0; native IReadOnlySet in DTOs - Bump Nullean.Argh + Nullean.Argh.Hosting to 0.8.0 - Delete ExporterParser: IReadOnlySet? now bound natively by argh - IsolatedBuildOptions and AssemblerBuildOptions include Exporters via repeated flags (--exporters Html --exporters Elasticsearch); [CollectionSyntax(Separator=",")] deferred until argh fixes [AsParameters] + IReadOnlySet interaction - IsolatedBuildCommand.Build and AssemblerCommands.Build use [AsParameters] DTO - [CommandName("build")] + [DefaultCommand] ready for MapAndRootAlias once the generator bug (missing { on subsequent methods) is fixed in argh - Map() used in place of MapAndRootAlias<> for now Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 5 +- .../Building/AssemblerBuildOptions.cs | 3 + .../IsolatedBuildOptions.cs | 3 +- .../IsolatedBuildService.cs | 2 +- .../IsolatedIndexService.cs | 2 +- .../docs-builder/Arguments/ExportOption.cs | 60 ------------------- .../Commands/Assembler/AssemblerCommands.cs | 57 ++++-------------- .../Commands/IsolatedBuildCommand.cs | 46 +++----------- .../docs-builder/Http/InMemoryBuildState.cs | 2 +- src/tooling/docs-builder/Program.cs | 4 +- 10 files changed, 32 insertions(+), 152 deletions(-) delete mode 100644 src/tooling/docs-builder/Arguments/ExportOption.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2318f99a66..134a6c48d7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,8 +69,9 @@ - - + + + diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs index cbd6108b5f..b08bdc9568 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs @@ -2,6 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Documentation; + namespace Elastic.Documentation.Assembler.Building; /// Options for an assembler build, bound from CLI flags via argh [AsParameters]. @@ -11,6 +13,7 @@ public record AssemblerBuildOptions public string? Environment { get; init; } public bool? MetadataOnly { get; init; } public bool? ShowHints { get; init; } + // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + IReadOnlySet interaction public IReadOnlySet? Exporters { get; init; } public bool? AssumeBuild { get; init; } } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index 283a3cd4e0..b69cbbc14f 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -14,8 +14,9 @@ public record IsolatedBuildOptions public bool? Strict { get; init; } public bool? AllowIndexing { get; init; } public bool? MetadataOnly { get; init; } + // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + IReadOnlySet interaction public IReadOnlySet? Exporters { get; init; } public string? CanonicalBaseUrl { get; init; } - public bool SkipOpenApi { get; init; } + public bool SkipApi { get; init; } public bool SkipCrossLinks { get; init; } } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 4f6b466060..2515538f6c 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -59,7 +59,7 @@ public async Task Build( var metadataOnly = options.MetadataOnly; var exporters = options.Exporters; var canonicalBaseUrl = options.CanonicalBaseUrl; - var skipOpenApi = options.SkipOpenApi; + var skipOpenApi = options.SkipApi; var skipCrossLinks = options.SkipCrossLinks; strict = IsStrict(strict); diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs index 6ab67a0ae6..f41a76173d 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs @@ -39,7 +39,7 @@ public async Task Index( MetadataOnly = true, Strict = false, Force = true, - SkipOpenApi = true, + SkipApi = true, Exporters = new HashSet { Elasticsearch } }, ctx: ctx); } diff --git a/src/tooling/docs-builder/Arguments/ExportOption.cs b/src/tooling/docs-builder/Arguments/ExportOption.cs deleted file mode 100644 index e09b746333..0000000000 --- a/src/tooling/docs-builder/Arguments/ExportOption.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using Elastic.Documentation; -using Nullean.Argh.Parsing; -using static Elastic.Documentation.Exporter; - -namespace Documentation.Builder.Arguments; - -/// -/// Parses a comma-separated exporter list into . -/// Use with [ArgumentParser(typeof(ExporterParser))] on command parameters. -/// -/// -/// Accepted values: html, es / elasticsearch, config, links, -/// state, llm / llmtext, redirect / redirects, metadata, -/// none, default. -/// -public class ExporterParser : IArgumentParser> -{ - public bool TryParse(string raw, out IReadOnlySet result) - { - result = ExportOptions.Default; - var set = new HashSet(); - var span = raw.AsSpan(); - var options = span.Split(','); - foreach (var option in options) - { - var export = span[option].Trim().ToString().ToLowerInvariant() switch - { - "llm" => LLMText, - "llmtext" => LLMText, - "es" => Elasticsearch, - "elasticsearch" => Elasticsearch, - "html" => Html, - "config" => Configuration, - "links" => LinkMetadata, - "state" => DocumentationState, - "redirects" => Redirects, - "redirect" => Redirects, - "none" => null, - "default" => AddDefaultReturnNull(set, ExportOptions.Default), - "metadata" => AddDefaultReturnNull(set, ExportOptions.MetadataOnly), - _ => throw new Exception($"Unknown exporter {span[option].Trim().ToString().ToLowerInvariant()}") - }; - if (export.HasValue) - _ = set.Add(export.Value); - } - result = set; - return true; - } - - private static Exporter? AddDefaultReturnNull(HashSet set, HashSet defaultSet) - { - foreach (var option in defaultSet) - _ = set.Add(option); - return null; - } -} diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index 23e2ae1e1c..b4b917d048 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -4,7 +4,6 @@ using System.IO.Abstractions; using Actions.Core.Services; -using Documentation.Builder.Arguments; using Documentation.Builder.Http; using Elastic.Documentation; using Elastic.Documentation.Assembler.Building; @@ -40,33 +39,19 @@ IEnvironmentVariables environmentVariables /// Equivalent to running assembler config init, assembler clone, and assembler build in sequence. /// /// docs-builder assemble - /// docs-builder assemble --environment staging --fetch-latest --exporters html,es + /// docs-builder assemble --environment staging --fetch-latest --exporters Html --exporters Elasticsearch /// docs-builder assemble --serve /// /// - /// Treat warnings as errors and fail the build on warnings - /// The environment to build /// Fetch the latest commit of the branch instead of the link registry entry ref /// Assume the repository folder already exists on disk (skip clone); primarily used for testing - /// Assume the build output already exists (skip build if index.html exists); primarily used for testing - /// Only emit documentation metadata to output (ignored when --exporters is also set) - /// Show hints from all documentation sets during the build - /// - /// Comma-separated exporter list. Values: html, es, config, links, state, llm, redirect, metadata, none, default. - /// Default: (html, config, links, state, redirect). - /// /// Serve the documentation on port 4000 after a successful build [CommandName("assemble")] public async Task Assemble( GlobalCliOptions _, - bool? strict = null, - string? environment = null, + [AsParameters] AssemblerBuildOptions buildOptions, bool? fetchLatest = null, bool? assumeCloned = null, - bool? assumeBuild = null, - bool? metadataOnly = null, - bool? showHints = null, - [ArgumentParser(typeof(ExporterParser))] IReadOnlySet? exporters = null, bool serve = false, CancellationToken ct = default ) @@ -75,23 +60,20 @@ public async Task Assemble( var cloneOptions = new AssemblerCloneOptions { - Strict = strict, Environment = environment, - FetchLatest = fetchLatest, AssumeCloned = assumeCloned + Strict = buildOptions.Strict, + Environment = buildOptions.Environment, + FetchLatest = fetchLatest, + AssumeCloned = assumeCloned }; var cloneService = new AssemblerCloneService(logFactory, assemblyConfiguration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(cloneService, cloneOptions, strict ?? false, + serviceInvoker.AddCommand(cloneService, cloneOptions, buildOptions.Strict ?? false, static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) ); - var buildOptions = new AssemblerBuildOptions - { - Strict = strict, Environment = environment, MetadataOnly = metadataOnly, - ShowHints = showHints, Exporters = exporters, AssumeBuild = assumeBuild - }; var readFs = FileSystemFactory.RealRead; var writeFs = FileSystemFactory.RealWrite; var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(buildService, (buildOptions, readFs, writeFs), strict ?? false, + serviceInvoker.AddCommand(buildService, (buildOptions, readFs, writeFs), buildOptions.Strict ?? false, static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.readFs, state.writeFs, ctx) ); var result = await serviceInvoker.InvokeAsync(ct); @@ -144,36 +126,17 @@ static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) } /// Build all cloned repositories into assembled documentation. - /// Treat warnings as errors and fail the build on warnings - /// The environment to build - /// Assume the build output already exists; primarily used for testing - /// Only emit documentation metadata to output (ignored when --exporters is also set) - /// Show hints from all documentation sets during the build - /// - /// Comma-separated exporter list. Values: html, es, config, links, state, llm, redirect, metadata, none, default. - /// Default: (html, config, links, state, redirect). - /// [NoOptionsInjection] public async Task Build( - bool? strict = null, - string? environment = null, - bool? assumeBuild = null, - bool? metadataOnly = null, - bool? showHints = null, - [ArgumentParser(typeof(ExporterParser))] IReadOnlySet? exporters = null, + [AsParameters] AssemblerBuildOptions options, CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); - var options = new AssemblerBuildOptions - { - Strict = strict, Environment = environment, AssumeBuild = assumeBuild, - MetadataOnly = metadataOnly, ShowHints = showHints, Exporters = exporters - }; var readFs = FileSystemFactory.RealRead; var writeFs = FileSystemFactory.RealWrite; var service = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, (options, readFs, writeFs), strict ?? false, + serviceInvoker.AddCommand(service, (options, readFs, writeFs), options.Strict ?? false, static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.readFs, state.writeFs, ctx) ); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index 82c9ffaa3f..7713137723 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -4,7 +4,6 @@ using System.IO.Abstractions; using Actions.Core.Services; -using Documentation.Builder.Arguments; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; @@ -28,55 +27,26 @@ IEnvironmentVariables environmentVariables /// /// /// - /// docs-builder - /// docs-builder -p ./my-docs -o .artifacts/html --strict - /// docs-builder --exporters html,es --canonical-base-url https://elastic.co/docs + /// docs-builder build + /// docs-builder build -p ./my-docs -o .artifacts/html --strict + /// docs-builder build --exporters Html --exporters Elasticsearch /// /// - /// -p, Defaults to the cwd/docs folder - /// -o, Defaults to .artifacts/html - /// Specifies the path prefix for URLs - /// Force a full rebuild of the destination folder - /// Treat warnings as errors and fail the build on warnings - /// Allow indexing and following of HTML files - /// Only emit documentation metadata to output (ignored when --exporters is also set) - /// - /// Comma-separated exporter list. Values: html, es, config, links, state, llm, redirect, metadata, none, default. - /// Default: (html, config, links, state, redirect). - /// - /// The base URL for the canonical URL tag - /// Run the build in memory without writing to disk - /// Skip OpenAPI documentation generation for faster builds [DefaultCommand] + [CommandName("build")] public async Task Build( GlobalCliOptions _, - string? path = null, - string? output = null, - string? pathPrefix = null, - bool? force = null, - bool? strict = null, - bool? allowIndexing = null, - bool? metadataOnly = null, - [ArgumentParser(typeof(ExporterParser))] IReadOnlySet? exporters = null, - string? canonicalBaseUrl = null, + [AsParameters] IsolatedBuildOptions options, bool inMemory = false, - bool skipApi = false, CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(path); - var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(path, output); - var options = new IsolatedBuildOptions - { - Path = path, Output = output, PathPrefix = pathPrefix, - Force = force, Strict = strict, AllowIndexing = allowIndexing, - MetadataOnly = metadataOnly, Exporters = exporters, - CanonicalBaseUrl = canonicalBaseUrl, SkipOpenApi = skipApi - }; - var strictCommand = service.IsStrict(strict); + var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(options.Path); + var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(options.Path, options.Output); + var strictCommand = service.IsStrict(options.Strict); serviceInvoker.AddCommand(service, (options, readFs, writeFs), strictCommand, static async (s, col, state, ctx) => await s.Build(col, state.readFs, state.options, state.writeFs, ctx) diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index 35dd2c48b6..7a71096022 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -185,7 +185,7 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) AllowIndexing = false, MetadataOnly = false, Exporters = ExportOptions.Default, - SkipOpenApi = true, + SkipApi = true, SkipCrossLinks = false }, _writeFs, // reuse MockFileSystem across builds for caching diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index f2c15b146e..eacb7fba66 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -31,7 +31,9 @@ _ = app.UseMiddleware(); _ = app.UseMiddleware(); - // Root default: `docs-builder` with no sub-command → build docs from cwd + // `docs-builder build` as a named command; also the root default via [DefaultCommand]. + // TODO: switch to MapAndRootAlias() once argh PR#19 generator bug is fixed + // (missing opening brace on methods emitted after the alias registration). _ = app.Map(); _ = app.Map(); From 01ce9f8e1b752fe6f0c4bd758a752ee787f89e55 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 10:29:46 +0200 Subject: [PATCH 04/27] feat(tooling): upgrade to Nullean.Argh 0.8.1; MapAndRootAlias for docs-builder build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump to 0.8.1 (fixes MapAndRootAlias generator bug — missing { on subsequent methods) - Switch Map() → MapAndRootAlias(): docs-builder build is now both a named command and the root default alias - IsolatedBuildOptions and AssemblerBuildOptions include IReadOnlySet? (repeated flags: --exporters Html --exporters Elasticsearch) - [CollectionSyntax(Separator=",")] deferred: [AsParameters] + [CollectionSyntax] still emits a stray closing brace in 0.8.1 generated code Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 +++--- .../Building/AssemblerBuildOptions.cs | 2 +- .../Elastic.Documentation.Isolated/IsolatedBuildOptions.cs | 2 +- src/tooling/docs-builder/Program.cs | 6 ++---- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 134a6c48d7..aded050716 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs index b08bdc9568..a634c886f6 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs @@ -13,7 +13,7 @@ public record AssemblerBuildOptions public string? Environment { get; init; } public bool? MetadataOnly { get; init; } public bool? ShowHints { get; init; } - // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + IReadOnlySet interaction + // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + [CollectionSyntax] interaction public IReadOnlySet? Exporters { get; init; } public bool? AssumeBuild { get; init; } } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index b69cbbc14f..104bcfea0c 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -14,7 +14,7 @@ public record IsolatedBuildOptions public bool? Strict { get; init; } public bool? AllowIndexing { get; init; } public bool? MetadataOnly { get; init; } - // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + IReadOnlySet interaction + // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + [CollectionSyntax] interaction public IReadOnlySet? Exporters { get; init; } public string? CanonicalBaseUrl { get; init; } public bool SkipApi { get; init; } diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index eacb7fba66..5bcf32ea23 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -31,10 +31,8 @@ _ = app.UseMiddleware(); _ = app.UseMiddleware(); - // `docs-builder build` as a named command; also the root default via [DefaultCommand]. - // TODO: switch to MapAndRootAlias() once argh PR#19 generator bug is fixed - // (missing opening brace on methods emitted after the alias registration). - _ = app.Map(); + // `docs-builder build` as a named command AND root default (`docs-builder` with no sub-command). + _ = app.MapAndRootAlias(); _ = app.Map(); _ = app.Map(); From 0a67952bb03dee3210eff934fc0c4cfb3870c2fb Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 13:12:23 +0200 Subject: [PATCH 05/27] feat(tooling): upgrade to Nullean.Argh 0.9.0 - Bump to 0.9.0 (fixes [AsParameters] + [CollectionSyntax] stray-brace generator bug) - Re-enable [CollectionSyntax(Separator=",")] on IsolatedBuildOptions.Exporters and AssemblerBuildOptions.Exporters: --exporters Html,Elasticsearch now works - Add ArghApp.TryArghIntrinsicCommand(args) pre-host fast path: --help, --version, __schema and __completion no longer trigger host construction or startup logs - Nullean.Argh.Interfaces added to Isolated and Assembler service projects Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 +++--- .../Building/AssemblerBuildOptions.cs | 3 ++- .../Elastic.Documentation.Assembler.csproj | 1 + .../Elastic.Documentation.Isolated.csproj | 1 + .../Elastic.Documentation.Isolated/IsolatedBuildOptions.cs | 4 +++- src/tooling/docs-builder/Program.cs | 5 +++++ 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index aded050716..dd4533d3e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs index a634c886f6..5f56d9b57a 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation; +using Nullean.Argh; namespace Elastic.Documentation.Assembler.Building; @@ -13,7 +14,7 @@ public record AssemblerBuildOptions public string? Environment { get; init; } public bool? MetadataOnly { get; init; } public bool? ShowHints { get; init; } - // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + [CollectionSyntax] interaction + [CollectionSyntax(Separator = ",")] public IReadOnlySet? Exporters { get; init; } public bool? AssumeBuild { get; init; } } diff --git a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj index 7755841ad1..27dfad62b3 100644 --- a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj +++ b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj @@ -16,6 +16,7 @@ + diff --git a/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj b/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj index 3b50392249..5fb7df66b5 100644 --- a/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj +++ b/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj @@ -8,6 +8,7 @@ + diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index 104bcfea0c..5be6622850 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -2,6 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Nullean.Argh; + namespace Elastic.Documentation.Isolated; /// Options for an isolated documentation set build, bound from CLI flags via argh [AsParameters]. @@ -14,7 +16,7 @@ public record IsolatedBuildOptions public bool? Strict { get; init; } public bool? AllowIndexing { get; init; } public bool? MetadataOnly { get; init; } - // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + [CollectionSyntax] interaction + [CollectionSyntax(Separator = ",")] public IReadOnlySet? Exporters { get; init; } public string? CanonicalBaseUrl { get; init; } public bool SkipApi { get; init; } diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index 5bcf32ea23..8c4cdebe8e 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -12,8 +12,13 @@ using Elastic.Documentation.ServiceDefaults; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Nullean.Argh; using Nullean.Argh.Hosting; +// Pre-host fast path: run --help, --version, __schema, __completion directly and exit +// before the host (and its startup logs) are ever constructed. +await ArghApp.TryArghIntrinsicCommand(args); + var builder = Host.CreateApplicationBuilder() .AddDocumentationServiceDefaults(args, (s, p) => { From e0d8641ed81a946d23edc8f9a97be7357d2574f4 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 13:58:15 +0200 Subject: [PATCH 06/27] docs(cli): help text overhaul, validation pass, and flag renames ElasticsearchIndexOptions: - Endpoint/ProxyAddress: string? -> Uri? with [UriScheme("http","https")] - BootstrapTimeout: int? (minutes) -> TimeSpan? with [TimeSpanRange("1s","60m")] - [Range] on SearchNumThreads, IndexNumThreads, BufferSize, MaxRetries - Rename NoAiEnrichment -> AiEnrichment, NoEis -> Eis (positive flags, clean --no-ai-enrichment / --no-eis negation; removes double-negative) - Full XML docs on all properties XML documentation: - IsolatedBuildOptions, AssemblerBuildOptions: XML docs on all properties - Namespace class summaries and remarks: assembler (navigation.yml unified site), codex (independent per-set navigation), inbound-links (link registry concept) - All assembler, codex, and inbound-links commands: complete and explaining concepts (assembler, codex, link registry, bloom filter), ordering requirements, and usage examples - Root commands (build, index, diff): tightened, added Co-Authored-By: Claude Sonnet 4.6 (1M context) --- ...Elastic.Documentation.Configuration.csproj | 1 + .../ElasticsearchEndpointConfigurator.cs | 106 ++++++++++++------ .../Building/AssemblerBuildOptions.cs | 15 +++ .../IsolatedBuildOptions.cs | 27 ++++- .../Commands/Assembler/AssemblerCommands.cs | 70 ++++++++---- .../Assembler/AssemblerIndexCommand.cs | 12 +- .../Assembler/AssemblerSitemapCommand.cs | 11 +- .../Commands/Assembler/BloomFilterCommands.cs | 13 ++- .../Assembler/ConfigurationCommands.cs | 11 +- .../Assembler/ContentSourceCommands.cs | 9 +- .../Commands/Assembler/DeployCommands.cs | 30 +++-- .../Commands/Assembler/NavigationCommands.cs | 6 +- .../Commands/Codex/CodexCommands.cs | 53 +++++---- .../Commands/Codex/CodexIndexCommand.cs | 7 +- .../Codex/CodexUpdateRedirectsCommand.cs | 9 +- .../docs-builder/Commands/DiffCommands.cs | 10 +- .../Commands/InboundLinkCommands.cs | 28 +++-- .../docs-builder/Commands/IndexCommand.cs | 9 +- .../Commands/IsolatedBuildCommand.cs | 6 +- 19 files changed, 285 insertions(+), 148 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index 0f96c00f34..5d907212d4 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs index d48a6e6a11..4b5ba1acfa 100644 --- a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs @@ -2,59 +2,103 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using System.Security.Cryptography.X509Certificates; using Elastic.Documentation.Diagnostics; +using Nullean.Argh; namespace Elastic.Documentation.Configuration; /// -/// Options record for configuring an Elasticsearch endpoint from CLI arguments. -/// Shared by all index commands (isolated, assembler, codex). +/// Elasticsearch connection and indexing options shared by all index commands. +/// Bind from CLI flags via argh [AsParameters]. /// public record ElasticsearchIndexOptions { - // endpoint options - public string? Endpoint { get; init; } + // --- endpoint --- + + /// -es,--endpoint, Elasticsearch endpoint URL. Falls back to env DOCUMENTATION_ELASTIC_URL. + [UriScheme("http", "https")] + public Uri? Endpoint { get; init; } + + /// API key for authentication. Falls back to env DOCUMENTATION_ELASTIC_APIKEY. public string? ApiKey { get; init; } + + /// Username for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_USERNAME. public string? Username { get; init; } + + /// Password for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_PASSWORD. public string? Password { get; init; } - // inference options - public bool? NoAiEnrichment { get; init; } + // --- inference --- + + /// Enable AI enrichment of documents using LLM-generated metadata (enabled by default). + public bool? AiEnrichment { get; init; } + + /// Number of search threads for the inference endpoint. + [Range(1, 128)] public int? SearchNumThreads { get; init; } + + /// Number of index threads for the inference endpoint. + [Range(1, 128)] public int? IndexNumThreads { get; init; } - public bool? NoEis { get; init; } - public int? BootstrapTimeout { get; init; } - // index options + /// Use the Elastic Inference Service to bootstrap the inference endpoint (enabled by default). + public bool? Eis { get; init; } + + /// How long to wait for the inference endpoint to become ready (e.g. 4m, 90s). + [TimeSpanRange("1s", "60m")] + public TimeSpan? BootstrapTimeout { get; init; } + + // --- index behavior --- + + /// Force a full reindex, discarding any incremental state. public bool? ForceReindex { get; init; } - // channel buffer options + /// Number of documents per bulk request. + [Range(1, 10_000)] public int? BufferSize { get; init; } + + /// Number of retry attempts for failed bulk items. + [Range(0, 20)] public int? MaxRetries { get; init; } - // connection options + /// Log every Elasticsearch request and response body; append ?pretty to all requests. public bool? DebugMode { get; init; } - public string? ProxyAddress { get; init; } - public string? ProxyPassword { get; init; } + + // --- proxy --- + + /// Route requests through this proxy URL. + [UriScheme("http", "https")] + public Uri? ProxyAddress { get; init; } + + /// Proxy server username. public string? ProxyUsername { get; init; } - // certificate options + /// Proxy server password. + public string? ProxyPassword { get; init; } + + // --- certificate --- + + /// Disable SSL certificate validation. Use only in controlled environments. public bool? DisableSslVerification { get; init; } + + /// SHA-256 fingerprint of a self-signed server certificate. public string? CertificateFingerprint { get; init; } + + /// Path to a PEM or DER certificate file for SSL validation. public string? CertificatePath { get; init; } + + /// Set when the certificate is an intermediate CA rather than the root. public bool? CertificateNotRoot { get; init; } } /// -/// Applies CLI options to an . Shared by all index commands. +/// Applies to an . Shared by all index commands. /// public static class ElasticsearchEndpointConfigurator { - /// - /// Applies the given options to the Elasticsearch endpoint configuration. - /// public static async Task ApplyAsync( ElasticsearchEndpoint cfg, ElasticsearchIndexOptions options, @@ -62,13 +106,8 @@ public static async Task ApplyAsync( IFileSystem fileSystem, Cancel ctx) { - if (!string.IsNullOrEmpty(options.Endpoint)) - { - if (!Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri)) - collector.EmitGlobalError($"'{options.Endpoint}' is not a valid URI"); - else - cfg.Uri = uri; - } + if (options.Endpoint is not null) + cfg.Uri = options.Endpoint; if (!string.IsNullOrEmpty(options.ApiKey)) cfg.ApiKey = options.ApiKey; @@ -81,8 +120,8 @@ public static async Task ApplyAsync( cfg.SearchNumThreads = options.SearchNumThreads.Value; if (options.IndexNumThreads.HasValue) cfg.IndexNumThreads = options.IndexNumThreads.Value; - if (options.NoEis.HasValue) - cfg.NoElasticInferenceService = options.NoEis.Value; + if (options.Eis == false) + cfg.NoElasticInferenceService = true; if (options.BufferSize.HasValue) cfg.BufferSize = options.BufferSize.Value; if (options.MaxRetries.HasValue) @@ -91,8 +130,8 @@ public static async Task ApplyAsync( cfg.DebugMode = options.DebugMode.Value; if (!string.IsNullOrEmpty(options.CertificateFingerprint)) cfg.CertificateFingerprint = options.CertificateFingerprint; - if (!string.IsNullOrEmpty(options.ProxyAddress)) - cfg.ProxyAddress = options.ProxyAddress; + if (options.ProxyAddress is not null) + cfg.ProxyAddress = options.ProxyAddress.ToString(); if (!string.IsNullOrEmpty(options.ProxyPassword)) cfg.ProxyPassword = options.ProxyPassword; if (!string.IsNullOrEmpty(options.ProxyUsername)) @@ -104,16 +143,13 @@ public static async Task ApplyAsync( if (!fileSystem.File.Exists(options.CertificatePath)) collector.EmitGlobalError($"'{options.CertificatePath}' does not exist"); var bytes = await fileSystem.File.ReadAllBytesAsync(options.CertificatePath, ctx); - var loader = X509CertificateLoader.LoadCertificate(bytes); - cfg.Certificate = loader; + cfg.Certificate = X509CertificateLoader.LoadCertificate(bytes); } - if (options.CertificateNotRoot.HasValue) cfg.CertificateIsNotRoot = options.CertificateNotRoot.Value; if (options.BootstrapTimeout.HasValue) - cfg.BootstrapTimeout = options.BootstrapTimeout.Value; - - if (options.NoAiEnrichment == true) + cfg.BootstrapTimeout = (int)options.BootstrapTimeout.Value.TotalMinutes; + if (options.AiEnrichment == false) cfg.EnableAiEnrichment = false; if (options.ForceReindex.HasValue) cfg.ForceReindex = options.ForceReindex.Value; diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs index 5f56d9b57a..1740954f5c 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs @@ -10,11 +10,26 @@ namespace Elastic.Documentation.Assembler.Building; /// Options for an assembler build, bound from CLI flags via argh [AsParameters]. public record AssemblerBuildOptions { + /// Treat warnings as errors. public bool? Strict { get; init; } + + /// Named deployment target, e.g. dev, staging, production. Determines which configuration branch and index names are used. public string? Environment { get; init; } + + /// Write only metadata files; skip HTML generation. Ignored when --exporters is also set. public bool? MetadataOnly { get; init; } + + /// Print documentation hints emitted during the build. public bool? ShowHints { get; init; } + + /// + /// Comma-separated list of exporters to run. + /// Values: Html, Elasticsearch, Configuration, LinkMetadata, DocumentationState, LLMText, Redirects. + /// Default: Html, Configuration, LinkMetadata, DocumentationState, Redirects. + /// [CollectionSyntax(Separator = ",")] public IReadOnlySet? Exporters { get; init; } + + /// Skip the build step when .artifacts/docs/index.html already exists. Intended for test scenarios only. public bool? AssumeBuild { get; init; } } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index 5be6622850..33d57e1bd4 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -9,16 +9,41 @@ namespace Elastic.Documentation.Isolated; /// Options for an isolated documentation set build, bound from CLI flags via argh [AsParameters]. public record IsolatedBuildOptions { + /// -p, Root directory of the documentation source. Defaults to cwd/docs. public string? Path { get; init; } + + /// -o, Destination for generated HTML. Defaults to .artifacts/html. public string? Output { get; init; } + + /// URL path prefix prepended to every generated link. public string? PathPrefix { get; init; } + + /// Delete and rebuild the output folder even if nothing changed. public bool? Force { get; init; } + + /// Treat warnings as errors. public bool? Strict { get; init; } + + /// Emit meta robots tags that allow search engine indexing. public bool? AllowIndexing { get; init; } + + /// Write only metadata files; skip HTML generation. Ignored when --exporters is also set. public bool? MetadataOnly { get; init; } - [CollectionSyntax(Separator = ",")] + + /// + /// Comma-separated list of exporters to run. + /// Values: Html, Elasticsearch, Configuration, LinkMetadata, DocumentationState, LLMText, Redirects. + /// Default: Html, Configuration, LinkMetadata, DocumentationState, Redirects. + /// + // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + [CollectionSyntax] interaction public IReadOnlySet? Exporters { get; init; } + + /// Base URL written into <link rel=canonical> tags. public string? CanonicalBaseUrl { get; init; } + + /// Skip OpenAPI spec generation for faster builds. public bool SkipApi { get; init; } + + /// Skip fetching cross-doc-set link indexes. public bool SkipCrossLinks { get; init; } } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index b4b917d048..e03d7a06f0 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -17,12 +17,6 @@ namespace Documentation.Builder.Commands.Assembler; -/// -/// Full assembler pipeline in one shot: init configuration, clone all repositories, then build assembled documentation. -/// -/// -/// Hoisted to the root scope via app.Map<AssembleOneShotCommand>() as the assemble command. -/// internal sealed class AssembleOneShotCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, @@ -32,20 +26,22 @@ internal sealed class AssembleOneShotCommand( IEnvironmentVariables environmentVariables ) { - /// - /// Full assembler pipeline: init configuration, clone all repositories, then build assembled documentation. - /// + /// Clone all repositories and build the unified documentation site in one step. /// - /// Equivalent to running assembler config init, assembler clone, and assembler build in sequence. + /// + /// The assembler clones multiple documentation repositories and builds them into a single unified site + /// composed by a shared navigation.yml. This command combines assembler config init, + /// assembler clone, and assembler build into a single invocation. + /// /// /// docs-builder assemble - /// docs-builder assemble --environment staging --fetch-latest --exporters Html --exporters Elasticsearch - /// docs-builder assemble --serve + /// docs-builder assemble --environment staging --fetch-latest + /// docs-builder assemble --exporters Html,Elasticsearch --serve /// /// - /// Fetch the latest commit of the branch instead of the link registry entry ref - /// Assume the repository folder already exists on disk (skip clone); primarily used for testing - /// Serve the documentation on port 4000 after a successful build + /// Fetch the HEAD of each branch instead of the pinned link-registry ref. + /// Skip cloning; assume repositories are already on disk. Useful for iterating on the build. + /// Serve the site on port 4000 after a successful build. [CommandName("assemble")] public async Task Assemble( GlobalCliOptions _, @@ -89,6 +85,23 @@ static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, s } } +/// Build a unified documentation site by composing multiple documentation sets under a shared navigation. +/// +/// +/// The assembler clones multiple documentation repositories and builds them into a single unified site. +/// A central navigation.yml defines the global structure, merging content from every repository +/// into one consistent navigation tree. +/// +/// +/// Typical workflow: +/// +/// docs-builder assembler config init # fetch configuration +/// docs-builder assembler clone # clone all repositories +/// docs-builder assembler build # build the unified site +/// docs-builder assembler serve # preview the result +/// +/// +/// internal sealed class AssemblerCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, @@ -98,11 +111,15 @@ internal sealed class AssemblerCommands( IEnvironmentVariables environmentVariables ) { - /// Clone all repositories configured in the assembler. - /// Treat warnings as errors and fail the build on warnings - /// The environment to clone for - /// Fetch the latest commit of the branch instead of the link registry entry ref - /// Assume repositories are already cloned; primarily used for testing + /// Clone all repositories listed in the assembler configuration. + /// + /// Run assembler config init first to fetch the repository list. Clones into a local + /// working directory; subsequent assembler build reads from there. + /// + /// Treat warnings as errors. + /// Named deployment target. Determines which repositories and branches are cloned. + /// Fetch the HEAD of each branch instead of the pinned link-registry ref. + /// Skip cloning; assume repositories are already on disk. [NoOptionsInjection] public async Task Clone( bool? strict = null, @@ -125,7 +142,11 @@ static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) return await serviceInvoker.InvokeAsync(ct); } - /// Build all cloned repositories into assembled documentation. + /// Build the unified site from all previously cloned repositories. + /// + /// Run after assembler clone. Reads every cloned repository, applies the shared navigation.yml, + /// and writes the unified site to .artifacts/docs/. + /// [NoOptionsInjection] public async Task Build( [AsParameters] AssemblerBuildOptions options, @@ -142,9 +163,10 @@ static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state. return await serviceInvoker.InvokeAsync(ct); } - /// Serve the output of an assembler build at http://localhost:4000. - /// Port to serve the documentation. Default: 4000 - /// Path to the built documentation. Defaults to the standard assembler output + /// Serve the output of a completed assembler build at http://localhost:4000. + /// Run after assembler build. Does not watch for file changes. + /// Port to listen on. Default: 4000. + /// Path to the built site. Defaults to .artifacts/docs/. [NoOptionsInjection] public async Task Serve(int port = 4000, string? path = null, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 9f67307170..5cf153eed8 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -24,16 +24,18 @@ internal sealed class AssemblerIndexCommand( IEnvironmentVariables environmentVariables ) { - /// - /// Index assembled documentation to Elasticsearch. - /// + /// Index the assembled documentation into Elasticsearch. /// - /// Calls docs-builder assembler build --exporters elasticsearch with full Elasticsearch option control. + /// + /// Runs an assembler build with only the Elasticsearch exporter enabled, then streams documents + /// to the cluster. The index name is derived from the environment name. + /// + /// Run after assembler build or use instead of it when indexing is the only goal. /// /// docs-builder assembler index --endpoint https://es:9200 --api-key KEY --environment staging /// /// - /// The environment name; becomes part of the index name + /// Named deployment target; becomes part of the Elasticsearch index name. [CommandName("index")] public async Task Index( GlobalCliOptions _, diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index 9b4d46ec13..ce7ad5f33f 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -23,15 +23,18 @@ internal sealed class AssemblerSitemapCommand( ICoreService githubActionsService ) { - /// - /// Generate sitemap.xml from the Elasticsearch index using content_last_updated dates. - /// + /// Generate sitemap.xml using accurate content_last_updated dates from Elasticsearch. /// + /// + /// The sitemap generated by assembler build uses the current date as a placeholder. + /// Run this command after assembler index to overwrite it with precise last-modified dates + /// sourced from the search index. + /// /// /// docs-builder assembler sitemap --endpoint https://es:9200 --api-key KEY --environment staging /// /// - /// The environment name used to resolve the Elasticsearch index + /// Named deployment target; used to resolve the correct Elasticsearch index. [CommandName("sitemap")] public async Task Sitemap( GlobalCliOptions _, diff --git a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs index d45136037b..5980764bb5 100644 --- a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs @@ -13,8 +13,13 @@ namespace Documentation.Builder.Commands.Assembler; internal sealed class BloomFilterCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector) { - /// Generate the bloom filter binary file from a local elastic/built-docs repository. - /// Path to the local elastic/built-docs repository + /// Build a bloom filter binary from a local legacy-docs repository. + /// + /// The bloom filter is a compact data structure that records which legacy URLs existed before migration. + /// It is used to verify redirect coverage: if a legacy URL is absent from the filter, any redirect + /// pointing to it cannot be validated. Run once after cloning the legacy-docs repository. + /// + /// Path to the local legacy-docs repository checkout. [NoOptionsInjection] public async Task Create(string builtDocsDir, CancellationToken ct = default) { @@ -31,8 +36,8 @@ public async Task Create(string builtDocsDir, CancellationToken ct = defaul return await serviceInvoker.InvokeAsync(ct); } - /// Look up whether a path exists in the bloom filter. - /// The URL path to look up + /// Test whether a URL path is recorded in the bloom filter. + /// URL path to look up (e.g. /guide/en/elasticsearch/reference/current/index.html). [NoOptionsInjection] public async Task Lookup(string path, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs index 7a9374f9c2..79afff95af 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs @@ -20,9 +20,14 @@ internal sealed class ConfigurationCommand( AssemblyConfiguration assemblyConfiguration ) { - /// Clone the assembler configuration folder into application data. - /// Git reference to clone. Defaults to main - /// Save the remote configuration locally in pwd so later commands can pick it up as a local source + /// Fetch the assembler configuration into local application data. + /// + /// All assembler and codex commands read their repository list from a central configuration repository. + /// Run this once before the first assembler clone or assemble invocation, and whenever + /// the configuration has changed upstream. + /// + /// Git ref to fetch. Defaults to main. + /// Write the configuration into cwd so subsequent commands treat it as a local override. [NoOptionsInjection] public async Task Init(string? gitRef = null, bool local = false, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index 0ae6e094d7..32fadf14c4 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -23,7 +23,7 @@ internal sealed class ContentSourceCommands( ICoreService githubActionsService ) { - /// Validate that all configured repositories have been published. + /// Verify that every repository in the assembler configuration has an active published entry in the link registry. [NoOptionsInjection] public async Task Validate(CancellationToken ct = default) { @@ -36,9 +36,10 @@ public async Task Validate(CancellationToken ct = default) return await serviceInvoker.InvokeAsync(ct); } - /// Match a repository to a branch or tag and determine whether it should be built. - /// Repository to match - /// Branch or tag to match against + /// Check whether a repository at a specific branch or tag should be included in the next build. + /// Exits 0 if the repository matches; 1 otherwise. Useful for conditional CI steps. + /// Repository slug to match (e.g. elastic/elasticsearch). + /// Branch name or version tag to test against. [NoOptionsInjection] public async Task Match([Argument] string? repository = null, [Argument] string? branchOrTag = null, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index 36e790b944..6a9ae396b4 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -22,11 +22,15 @@ internal sealed class DeployCommands( ICoreService githubActionsService ) { - /// Create a sync plan for deploying built documentation to S3. - /// The environment to deploy to - /// The S3 bucket name to deploy to - /// The file path to write the plan to - /// Maximum percentage of deletions allowed in the plan (as a float) + /// Compute a diff of what would change when deploying to S3 and write it to a plan file. + /// + /// Two-step deployment: plan computes the diff and writes a plan file; apply executes it. + /// Review the plan before applying to avoid accidental mass deletions. + /// + /// Named deployment target. + /// S3 bucket to deploy to. + /// Path to write the plan file. Defaults to stdout. + /// Abort if the plan would delete more than this percentage of objects (0–100). [NoOptionsInjection] public async Task Plan(string environment, string s3BucketName, string @out = "", float? deleteThreshold = null, CancellationToken ct = default) { @@ -39,10 +43,11 @@ static async (s, collector, state, ctx) => await s.Plan(collector, state.environ return await serviceInvoker.InvokeAsync(ct); } - /// Apply a previously generated sync plan to deploy documentation to S3. - /// The environment to deploy to - /// The S3 bucket name to deploy to - /// Path to the plan file generated by assembler deploy plan + /// Upload the changes described in a plan file to S3. + /// Run after assembler deploy plan. Applies the pre-computed diff to the S3 bucket. + /// Named deployment target. + /// S3 bucket to deploy to. + /// Path to the plan file produced by assembler deploy plan. [NoOptionsInjection] public async Task Apply(string environment, string s3BucketName, string planFile, CancellationToken ct = default) { @@ -55,9 +60,10 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.enviro return await serviceInvoker.InvokeAsync(ct); } - /// Refresh the redirects mapping in CloudFront's KeyValueStore. - /// The environment whose redirects to update - /// Path to the redirects mapping pre-generated by assembler build + /// Push the redirects mapping to CloudFront's KeyValueStore. + /// Run after assembler build produces a redirects.json. + /// Named deployment target. + /// Path to redirects.json. Defaults to .artifacts/docs/redirects.json. [NoOptionsInjection] public async Task UpdateRedirects(string environment, string? redirectsFile = null, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index 07e9b2e2ce..bc94944066 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -21,7 +21,7 @@ internal sealed class NavigationCommands( IConfigurationContext configurationContext ) { - /// Validate that navigation.yml has no colliding path prefixes and all URLs are unique. + /// Check navigation.yml for duplicate path prefixes and non-unique URLs. [NoOptionsInjection] public async Task Validate(CancellationToken ct = default) { @@ -31,8 +31,8 @@ public async Task Validate(CancellationToken ct = default) return await serviceInvoker.InvokeAsync(ct); } - /// Validate that all links in a local links.json do not collide with navigation path prefixes. - /// Path to links.json. Defaults to .artifacts/docs/html/links.json + /// Check that no link in a local links.json conflicts with a path prefix defined in navigation.yml. + /// Path to links.json. Defaults to .artifacts/docs/html/links.json. [NoOptionsInjection] public async Task ValidateLinkReference([Argument] string? file = null, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index c3ca5f4383..951e9920ff 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -20,9 +20,14 @@ namespace Documentation.Builder.Commands.Codex; -/// -/// Build documentation codexes from multiple isolated documentation sets. -/// +/// Build a documentation portal over multiple independent documentation sets, each with its own navigation. +/// +/// +/// A codex is a portal composed of several documentation sets. Unlike the assembler, each set retains +/// its own navigation structure — there is no merged global navigation tree. The codex configuration +/// (codex.yml) lists which repositories to include and how to compose the portal. +/// +/// internal sealed class CodexCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, @@ -31,9 +36,7 @@ internal sealed class CodexCommands( IEnvironmentVariables environmentVariables ) { - /// - /// Clone and build a documentation codex in one step. - /// + /// Clone all repositories and build the portal in one step. /// /// /// docs-builder codex ./codex.yml @@ -41,12 +44,12 @@ IEnvironmentVariables environmentVariables /// docs-builder codex ./codex.yml --serve /// /// - /// Path to the codex.yml configuration file - /// Treat warnings as errors and fail on warnings - /// Fetch the latest commit even if already cloned - /// Assume repositories are already cloned - /// Output directory for the built codex - /// Serve the documentation on port 4000 after a successful build + /// Path to the codex.yml configuration file. + /// Treat warnings as errors. + /// Fetch the HEAD of each branch instead of the pinned ref. + /// Skip cloning; assume repositories are already on disk. + /// Output directory for the built portal. Defaults to .artifacts/codex/. + /// Serve the portal on port 4000 after a successful build. [DefaultCommand] public async Task CloneAndBuild( GlobalCliOptions _, @@ -114,11 +117,11 @@ public async Task CloneAndBuild( return result; } - /// Clone all repositories defined in the codex configuration. - /// Path to the codex.yml configuration file - /// Treat warnings as errors and fail on warnings - /// Fetch the latest commit even if already cloned - /// Assume repositories are already cloned + /// Clone all repositories listed in the codex configuration. + /// Path to the codex.yml configuration file. + /// Treat warnings as errors. + /// Fetch the HEAD of each branch instead of the pinned ref. + /// Skip cloning; assume repositories are already on disk. [NoOptionsInjection] public async Task Clone( [Argument] string config, @@ -161,10 +164,11 @@ public async Task Clone( return await serviceInvoker.InvokeAsync(ct); } - /// Build all documentation sets from already-cloned repositories. - /// Path to the codex.yml configuration file - /// Treat warnings as errors and fail on warnings - /// Output directory for the built codex + /// Build the portal from previously cloned repositories. + /// Run after codex clone. + /// Path to the codex.yml configuration file. + /// Treat warnings as errors. + /// Output directory. Defaults to .artifacts/codex/. [NoOptionsInjection] public async Task Build( [Argument] string config, @@ -213,9 +217,10 @@ public async Task Build( return await serviceInvoker.InvokeAsync(ct); } - /// Serve the built codex documentation at http://localhost:4000. - /// Port to serve on. Default: 4000 - /// Path to the codex output directory + /// Serve the built portal at http://localhost:4000. + /// Run after codex build. Does not rebuild on file changes. + /// Port to listen on. Default: 4000. + /// Path to the portal output. Defaults to .artifacts/codex/docs/. [NoOptionsInjection] public async Task Serve(int port = 4000, string? path = null, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 1e2d34ce85..abda8dd459 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -29,15 +29,14 @@ internal sealed class CodexIndexCommand( IEnvironmentVariables environmentVariables ) { - /// - /// Index a built codex documentation set to Elasticsearch. - /// + /// Index the built portal documentation into Elasticsearch. /// + /// Run after codex build. Streams documents from all included documentation sets to the cluster. /// /// docs-builder codex index ./codex.yml --endpoint https://es:9200 --api-key KEY /// /// - /// Path to the codex.yml configuration file + /// Path to the codex.yml configuration file. public async Task Index( GlobalCliOptions _, [Argument] string config, diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index 9a813c0620..1afa56733f 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -20,10 +20,11 @@ internal sealed class CodexUpdateRedirectsCommand( ILoggerFactory logFactory ) { - /// Refresh the redirects mapping in CloudFront's KeyValueStore for codex. - /// Path to the codex configuration file (used to resolve environment) - /// The environment to deploy to. Defaults to config or ENVIRONMENT env var - /// Path to the redirects mapping. Defaults to .artifacts/codex/docs/redirects.json + /// Push the codex redirects mapping to CloudFront's KeyValueStore. + /// Run after codex build produces a redirects.json. + /// Path to the codex.yml configuration file (used to resolve the environment). + /// Named deployment target. Defaults to the value in codex.yml or the ENVIRONMENT env var. + /// Path to redirects.json. Defaults to .artifacts/codex/docs/redirects.json. public async Task UpdateRedirects( GlobalCliOptions _, [Argument] string config, diff --git a/src/tooling/docs-builder/Commands/DiffCommands.cs b/src/tooling/docs-builder/Commands/DiffCommands.cs index 1055863550..e6196a3ca4 100644 --- a/src/tooling/docs-builder/Commands/DiffCommands.cs +++ b/src/tooling/docs-builder/Commands/DiffCommands.cs @@ -19,10 +19,12 @@ internal sealed class DiffCommand( IConfigurationContext configurationContext ) { - /// - /// Validate redirect updates in the current branch using the redirect file against changes reported by git. - /// - /// -p, Defaults to the cwd/docs folder + /// Verify every renamed or removed page in the current branch has a redirect entry. + /// + /// Compares the git diff of the working branch against the redirect file. Exits 1 if any moved + /// or deleted page is missing a redirect entry. Run before merging to catch broken links early. + /// + /// -p, Root of the documentation source. Defaults to cwd/docs. [NoOptionsInjection] [CommandName("diff")] public async Task Validate(string? path = null, CancellationToken ct = default) diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index f192055d34..e93f5239c7 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -13,11 +13,19 @@ namespace Documentation.Builder.Commands; +/// Validate cross-doc-set links against the published link registry. +/// +/// +/// Every documentation set publishes a links.json file containing the URLs of all its pages. +/// These files are aggregated into a shared link registry. Inbound-links commands validate that +/// cross-links between documentation sets resolve to real pages in the registry. +/// +/// internal sealed class InboundLinkCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector) { private readonly LinkIndexService _linkIndexService = new(logFactory, FileSystemFactory.RealRead); - /// Validate all published cross-links across all published links.json files. + /// Validate all cross-links across every published links.json in the registry. [NoOptionsInjection] public async Task ValidateAll(CancellationToken ct = default) { @@ -26,9 +34,9 @@ public async Task ValidateAll(CancellationToken ct = default) return await serviceInvoker.InvokeAsync(ct); } - /// Validate cross-links for a specific repository. - /// Source repository - /// Target repository + /// Validate all cross-links originating from or targeting a specific repository. + /// Only check links published by this repository slug. + /// Only check links that point to this repository slug. [NoOptionsInjection] public async Task Validate(string? from = null, string? to = null, CancellationToken ct = default) { @@ -39,11 +47,13 @@ static async (s, collector, state, ctx) => await s.CheckRepository(collector, st return await serviceInvoker.InvokeAsync(ct); } - /// - /// Validate a locally published links.json against all published link registries. - /// - /// Path to links.json. Defaults to .artifacts/docs/html/links.json - /// -p, Defaults to the cwd folder + /// Validate a locally built links.json against the published link registry. + /// + /// Use this to verify cross-links before publishing. The local links.json is checked against + /// all currently published registries to ensure every outbound cross-link resolves. + /// + /// Path to links.json. Defaults to .artifacts/docs/html/links.json. + /// -p, Root of the documentation source. Defaults to cwd. [NoOptionsInjection] public async Task ValidateLinkReference(string? file = null, string? path = null, CancellationToken ct = default) { diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index ed287150f1..bb5d38abe0 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -21,11 +21,12 @@ internal sealed class IndexCommand( IEnvironmentVariables environmentVariables ) { - /// - /// Index a single documentation set to Elasticsearch. - /// + /// Index a single documentation set into Elasticsearch. /// - /// Calls docs-builder --exporters elasticsearch with full control over all Elasticsearch options. + /// + /// Builds the documentation set in metadata-only mode and streams the output to Elasticsearch. + /// Does not write HTML to disk. Requires a running cluster and valid credentials. + /// /// /// docs-builder index --endpoint https://localhost:9200 --api-key YOUR_KEY /// docs-builder index --endpoint https://es:9200 --username elastic --password secret diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index 7713137723..b58f061d40 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -22,14 +22,12 @@ internal sealed class IsolatedBuildCommand( IEnvironmentVariables environmentVariables ) { - /// - /// Builds a source documentation set folder. - /// + /// Build a single documentation set from source. /// /// /// docs-builder build /// docs-builder build -p ./my-docs -o .artifacts/html --strict - /// docs-builder build --exporters Html --exporters Elasticsearch + /// docs-builder build --exporters Html,Elasticsearch /// /// [DefaultCommand] From 3bcb95274fd378e1a825544d771f93ad60fafb59 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 14:48:56 +0200 Subject: [PATCH 07/27] =?UTF-8?q?docs(cli):=20help=20text=20pass=20?= =?UTF-8?q?=E2=80=94=20summaries,=20remarks,=20no=20code=20blocks,=20names?= =?UTF-8?q?pace=20summaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChangelogCommands: add class-level summary; rewrite all method summaries to one terse sentence; move detail to ; remove invocation blocks - All commands: remove CLI invocation examples from throughout - GlobalCliOptions: remove manual enum listing from --log-level summary (argh renders allowed values automatically) - AssemblerBuildOptions, IsolatedBuildOptions: add XML docs to properties (visible in IDE; argh cross-assembly doc limitation tracked separately) - Nested assembler namespaces: add class-level to BloomFilterCommands, ConfigurationCommand, ContentSourceCommands, DeployCommands, NavigationCommands so they appear in assembler --help listings - ServeCommand, MoveCommand, FormatCommand, IndexCommand: tighten summaries and move detail to without code blocks Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Commands/Assembler/AssemblerCommands.cs | 11 --- .../Assembler/AssemblerIndexCommand.cs | 3 - .../Assembler/AssemblerSitemapCommand.cs | 3 - .../Commands/Assembler/BloomFilterCommands.cs | 1 + .../Assembler/ConfigurationCommands.cs | 1 + .../Assembler/ContentSourceCommands.cs | 1 + .../Commands/Assembler/DeployCommands.cs | 1 + .../Commands/Assembler/NavigationCommands.cs | 1 + .../docs-builder/Commands/ChangelogCommand.cs | 81 +++++++++---------- .../Commands/Codex/CodexCommands.cs | 5 -- .../Commands/Codex/CodexIndexCommand.cs | 3 - .../docs-builder/Commands/IndexCommand.cs | 4 - .../Commands/IsolatedBuildCommand.cs | 7 +- .../docs-builder/Commands/MoveCommand.cs | 36 +++------ .../docs-builder/Commands/ServeCommand.cs | 12 +-- src/tooling/docs-builder/GlobalCliOptions.cs | 2 +- 16 files changed, 56 insertions(+), 116 deletions(-) diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index e03d7a06f0..01e67bd764 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -33,11 +33,6 @@ IEnvironmentVariables environmentVariables /// composed by a shared navigation.yml. This command combines assembler config init, /// assembler clone, and assembler build into a single invocation. /// - /// - /// docs-builder assemble - /// docs-builder assemble --environment staging --fetch-latest - /// docs-builder assemble --exporters Html,Elasticsearch --serve - /// /// /// Fetch the HEAD of each branch instead of the pinned link-registry ref. /// Skip cloning; assume repositories are already on disk. Useful for iterating on the build. @@ -94,12 +89,6 @@ static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, s /// /// /// Typical workflow: -/// -/// docs-builder assembler config init # fetch configuration -/// docs-builder assembler clone # clone all repositories -/// docs-builder assembler build # build the unified site -/// docs-builder assembler serve # preview the result -/// /// /// internal sealed class AssemblerCommands( diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 5cf153eed8..26412a93cc 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -31,9 +31,6 @@ IEnvironmentVariables environmentVariables /// to the cluster. The index name is derived from the environment name. /// /// Run after assembler build or use instead of it when indexing is the only goal. - /// - /// docs-builder assembler index --endpoint https://es:9200 --api-key KEY --environment staging - /// /// /// Named deployment target; becomes part of the Elasticsearch index name. [CommandName("index")] diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index ce7ad5f33f..d0e2bbc825 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -30,9 +30,6 @@ ICoreService githubActionsService /// Run this command after assembler index to overwrite it with precise last-modified dates /// sourced from the search index. /// - /// - /// docs-builder assembler sitemap --endpoint https://es:9200 --api-key KEY --environment staging - /// /// /// Named deployment target; used to resolve the correct Elasticsearch index. [CommandName("sitemap")] diff --git a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs index 5980764bb5..2d1fafd57e 100644 --- a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs @@ -11,6 +11,7 @@ namespace Documentation.Builder.Commands.Assembler; +/// Build and query the bloom filter used for legacy-URL redirect coverage. internal sealed class BloomFilterCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector) { /// Build a bloom filter binary from a local legacy-docs repository. diff --git a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs index 79afff95af..e617afd6d5 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs @@ -14,6 +14,7 @@ namespace Documentation.Builder.Commands.Assembler; +/// Fetch and manage the central assembler configuration repository. internal sealed class ConfigurationCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index 32fadf14c4..a666ff3def 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -15,6 +15,7 @@ namespace Documentation.Builder.Commands.Assembler; +/// Inspect and validate repository entries in the link registry. internal sealed class ContentSourceCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index 6a9ae396b4..ca688f7bfb 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -14,6 +14,7 @@ namespace Documentation.Builder.Commands.Assembler; +/// Deploy built documentation to S3 and update CloudFront redirect rules. internal sealed class DeployCommands( AssemblyConfiguration assemblyConfiguration, IDiagnosticsCollector collector, diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index bc94944066..e04194b716 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -14,6 +14,7 @@ namespace Documentation.Builder.Commands.Assembler; +/// Validate the global navigation structure and cross-doc-set link references. internal sealed class NavigationCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index b2d9017407..50315c4edf 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -26,6 +26,7 @@ namespace Documentation.Builder.Commands; +/// Create, bundle, and publish changelog entries. internal sealed partial class ChangelogCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, @@ -42,16 +43,17 @@ IEnvironmentVariables environmentVariables private readonly IFileSystem _fileSystem = FileSystemFactory.RealRead; private readonly ILogger _logger = logFactory.CreateLogger(); - /// - /// Initialize changelog configuration and folder structure. Creates changelog.yml from the example template in the docs folder (discovered via docset.yml when present, or at PATH/docs which is created if needed), and creates changelog and releases subdirectories if they do not exist. - /// When changelog.yml already exists and --changelog-dir or --bundles-dir is specified, updates the bundle.directory and/or bundle.output_directory fields accordingly. - /// When creating a new changelog.yml, seeds bundle.owner, bundle.repo, and bundle.link_allow_repos from git remote origin (github.com only) and/or --owner / --repo. - /// - /// Optional: Repository root path. Defaults to the output of pwd (current directory). Docs folder is PATH/docs, created if it does not exist. - /// Optional: Path to changelog directory. Defaults to DOCS_FOLDER/changelog. - /// Optional: Path to bundles output directory. Defaults to DOCS_FOLDER/releases. - /// Optional: GitHub owner for bundle defaults and link_allow_repos seeding. Overrides the owner inferred from git remote origin. - /// Optional: GitHub repository name for bundle defaults and link_allow_repos seeding. Overrides the repo inferred from git remote origin. + /// Create changelog.yml and the changelog/releases directory structure. + /// + /// Discovers the docs folder via docset.yml; falls back to creating PATH/docs. + /// When changelog.yml already exists, updates only the paths specified via or . + /// Seeds bundle.owner, bundle.repo, and bundle.link_allow_repos from the git remote origin when available. + /// + /// Repository root. Defaults to cwd. + /// Changelog entry directory. Defaults to docs/changelog. + /// Bundle output directory. Defaults to docs/releases. + /// GitHub owner for seeding bundle defaults. Overrides the value inferred from git remote origin. + /// GitHub repository name for seeding bundle defaults. Overrides the value inferred from git remote origin. [NoOptionsInjection] public Task Init( string? path = null, @@ -204,9 +206,7 @@ public Task Init( return Task.FromResult(0); } - /// - /// Add a new changelog from command-line input - /// + /// Create a new changelog entry YAML file. /// Optional: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05"). If not specified, will be inferred from repository or config defaults. /// Optional: What users must do to mitigate /// Optional: Area(s) affected (comma-separated or specify multiple times) @@ -479,9 +479,12 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st return await serviceInvoker.InvokeAsync(ctx); } - /// - /// Bundle changelog files. Can use either profile-based bundling (for example, "bundle elasticsearch-release 9.2.0") or command-line options (for example, "bundle --all") Only one command-line filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, `--release-version`, or `--report`. - /// + /// Aggregate changelog entries matching a filter into a single bundle YAML. + /// + /// Accepts either a named profile from changelog.yml (e.g. bundle my-release 9.2.0) or + /// an explicit filter flag. Exactly one filter must be specified: --all, --input-products, + /// --prs, --issues, --release-version, or --report. + /// /// Optional: Profile name from bundle.profiles in config (for example, "elasticsearch-release"). When specified, the second argument is the version or promotion report URL. /// Optional: Version number or promotion report URL/path when using a profile (for example, "9.2.0" or "https://buildkite.../promotion-report.html") /// Optional: Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source (for example, "bundle serverless-release 2026-02 ./report.html"). @@ -848,11 +851,11 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s return await serviceInvoker.InvokeAsync(ctx); } - /// - /// Remove changelog files. Can use either profile-based removal (e.g., "remove elasticsearch-release 9.2.0") or raw flags (e.g., "remove --all"). - /// When a file is referenced by an unresolved bundle, the command blocks by default to prevent breaking - /// the changelog directive. Use --force to override. - /// + /// Delete changelog entry files matching a filter. + /// + /// Blocks when a file is referenced by an unresolved bundle to avoid breaking the {changelog} + /// directive in published documentation. Pass --force to override. + /// /// Optional: Profile name from bundle.profiles in config (for example, "elasticsearch-release"). When specified, the second argument is the version or promotion report URL. /// Optional: Version number or promotion report URL/path when using a profile (for example, "9.2.0" or "https://buildkite.../promotion-report.html") /// Optional: Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source. @@ -1082,9 +1085,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s return await serviceInvoker.InvokeAsync(ctx); } - /// - /// Render bundled changelog(s) to markdown or asciidoc files - /// + /// Render one or more changelog bundles to Markdown or AsciiDoc. /// Required: Bundle input(s) in format "bundle-file-path|changelog-file-path|repo|link-visibility" (use pipe as delimiter). To merge multiple bundles, separate them with commas. Only bundle-file-path is required. link-visibility can be "hide-links" or "keep-links" (default). Use "hide-links" for private repositories; when set, all PR and issue links for each affected entry are hidden (entries may have multiple links via the prs and issues arrays). Paths support tilde (~) expansion and relative paths. /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// Optional: Output file type. Valid values: "markdown" or "asciidoc". Defaults to "markdown" @@ -1145,9 +1146,7 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s return await serviceInvoker.InvokeAsync(ctx); } - /// - /// Create changelogs from a GitHub release - /// + /// Create changelog entries from the PRs referenced in a GitHub release. /// Required: GitHub repository in owner/repo format (e.g., "elastic/elasticsearch" or just "elasticsearch" which defaults to elastic/elasticsearch) /// Optional: Version tag to fetch (e.g., "v9.0.0", "9.0.0"). Defaults to "latest" /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' @@ -1211,9 +1210,8 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c return await serviceInvoker.InvokeAsync(ctx); } - /// - /// Amend a bundle with additional changelog entries, creating an immutable .amend-N.yaml file. - /// + /// Append additional changelog entries to a published bundle without modifying it. + /// Creates an immutable .amend-N.yaml sidecar file alongside the original bundle. /// Required: Path to the original bundle file to amend /// Required: Path(s) to changelog YAML file(s) to add as comma-separated values (e.g., --add "file1.yaml,file2.yaml"). Supports tilde (~) expansion and relative paths. /// Optional: Copy the contents of each changelog file into the entries array. Use --no-resolve to explicitly turn off resolve (overrides inference from original bundle). @@ -1263,9 +1261,12 @@ async static (s, collector, state, ctx) => await s.AmendBundle(collector, state, return await serviceInvoker.InvokeAsync(ctx); } - /// - /// (CI) Evaluate a PR for changelog generation eligibility. Performs pre-flight checks (body-only edit, bot loop, manual edit), loads config, checks label rules, resolves title/type, and sets GitHub Actions outputs. - /// + /// (CI) Evaluate a pull request for changelog generation eligibility and set GitHub Actions outputs. + /// + /// Runs pre-flight checks (body-only edit, bot loop, manual edit), applies label rules from + /// changelog.yml, and resolves the entry type and title. Designed to be called from a + /// GitHub Actions workflow step. + /// /// Path to the changelog.yml configuration file /// GitHub repository owner /// GitHub repository name @@ -1331,10 +1332,6 @@ async static (s, collector, state, ctx) => await s.EvaluatePr(collector, state, return await serviceInvoker.InvokeAsync(ctx); } - /// - /// Expands a CLI array parameter where each element may be comma-separated into a flat list of values. - /// Filters out blank entries. - /// private static List ExpandCommaSeparated(string[]? values) { if (values is not { Length: > 0 }) @@ -1351,10 +1348,6 @@ private static List ExpandCommaSeparated(string[]? values) return result; } - /// - /// Returns a path suitable for changelog.yml config (relative to repo when possible, forward slashes). - /// Quotes the value if it contains YAML-special characters. - /// private static string GetPathForConfig(string repoPath, string targetPath) { var relativePath = Path.GetRelativePath(repoPath, targetPath); @@ -1384,10 +1377,8 @@ private string ApplyChangelogInitBundleRepoSeed(string content, string? ownerCli return ChangelogTemplateSeeder.ApplyBundleRepoSeed(content, ownerCli, repoCli, gitOwner, gitRepo); } - /// - /// Upload changelog or bundle artifacts to S3 or Elasticsearch. - /// Uses content-hash–based incremental upload: only files whose content has changed are transferred. - /// + /// Upload changelog entries or bundle artifacts to S3 or Elasticsearch. + /// Uses content-hash–based incremental transfer — only changed files are uploaded. /// Artifact type to upload: 'changelog' (individual entries) or 'bundle' (consolidated bundles). /// Upload destination: 's3' or 'elasticsearch'. /// S3 bucket name (required when target is 's3'). diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 951e9920ff..23421f8e79 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -38,11 +38,6 @@ IEnvironmentVariables environmentVariables { /// Clone all repositories and build the portal in one step. /// - /// - /// docs-builder codex ./codex.yml - /// docs-builder codex ./codex.yml --strict --fetch-latest - /// docs-builder codex ./codex.yml --serve - /// /// /// Path to the codex.yml configuration file. /// Treat warnings as errors. diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index abda8dd459..16b3691bcf 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -32,9 +32,6 @@ IEnvironmentVariables environmentVariables /// Index the built portal documentation into Elasticsearch. /// /// Run after codex build. Streams documents from all included documentation sets to the cluster. - /// - /// docs-builder codex index ./codex.yml --endpoint https://es:9200 --api-key KEY - /// /// /// Path to the codex.yml configuration file. public async Task Index( diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index bb5d38abe0..ad0004a648 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -27,10 +27,6 @@ IEnvironmentVariables environmentVariables /// Builds the documentation set in metadata-only mode and streams the output to Elasticsearch. /// Does not write HTML to disk. Requires a running cluster and valid credentials. /// - /// - /// docs-builder index --endpoint https://localhost:9200 --api-key YOUR_KEY - /// docs-builder index --endpoint https://es:9200 --username elastic --password secret - /// /// [CommandName("index")] public async Task Index( diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index b58f061d40..369c68f563 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -24,11 +24,8 @@ IEnvironmentVariables environmentVariables { /// Build a single documentation set from source. /// - /// - /// docs-builder build - /// docs-builder build -p ./my-docs -o .artifacts/html --strict - /// docs-builder build --exporters Html,Elasticsearch - /// + /// Locates the documentation root by searching for a docset.yml file starting at .Path. + /// The output directory is wiped and rebuilt on each run unless incremental build detects no changes. /// [DefaultCommand] [CommandName("build")] diff --git a/src/tooling/docs-builder/Commands/MoveCommand.cs b/src/tooling/docs-builder/Commands/MoveCommand.cs index 0b29a0c332..45ee274887 100644 --- a/src/tooling/docs-builder/Commands/MoveCommand.cs +++ b/src/tooling/docs-builder/Commands/MoveCommand.cs @@ -19,19 +19,11 @@ internal sealed class RefactorCommands( IConfigurationContext configurationContext ) { - /// - /// Move a file or folder and update all links in the documentation. - /// - /// - /// - /// docs-builder mv ./docs/old-page.md ./docs/new-page.md - /// docs-builder mv ./docs/old-section ./docs/new-section --dry-run - /// - /// - /// The source file or folder path to move from - /// The target file or folder path to move to - /// -p, Defaults to the cwd folder - /// Preview the move operation without applying changes + /// Move a file or folder and rewrite all inbound links across the documentation set. + /// Source file or folder path. + /// Destination file or folder path. + /// -p, Documentation root. Defaults to cwd. + /// Print the changes that would be made without applying them. [CommandName("mv")] public async Task Move( GlobalCliOptions _, @@ -53,19 +45,11 @@ async static (s, collector, state, ctx) => await s.Move(collector, state.source, return await serviceInvoker.InvokeAsync(ct); } - /// - /// Format documentation files by fixing common issues such as irregular spacing. - /// - /// - /// Exactly one of --check or --write must be specified. - /// - /// docs-builder format --check - /// docs-builder format --write -p ./my-docs - /// - /// - /// -p, Path to the documentation folder. Defaults to cwd - /// Check if files need formatting without modifying them (exits with code 1 if formatting is needed) - /// Write formatting changes to files + /// Fix common formatting issues (irregular spacing, trailing whitespace) across documentation files. + /// Exactly one of --check or --write must be specified. + /// -p, Documentation root. Defaults to cwd. + /// Report files that need formatting without modifying them. Exits 1 when any file is out of format. + /// Apply formatting changes in place. [CommandName("format")] public async Task Format( GlobalCliOptions _, diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index f9dbd3c87b..a9af5bec89 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -15,16 +15,8 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont { private readonly ILogger _logger = logFactory.CreateLogger(); - /// - /// Continuously serve a documentation folder at http://localhost:3000. - /// - /// - /// File system changes are reflected without restarting the server. - /// - /// docs-builder serve - /// docs-builder serve -p ./my-docs --port 8080 - /// - /// + /// Serve a documentation folder at http://localhost:3000 with live reload. + /// File-system changes are reflected without restarting the server. /// -p, Path to serve. Defaults to the cwd/docs folder /// Port to serve the documentation. Default: 3000 /// Special flag for dotnet watch optimizations during development diff --git a/src/tooling/docs-builder/GlobalCliOptions.cs b/src/tooling/docs-builder/GlobalCliOptions.cs index 3b39d216e1..fa3752aa8a 100644 --- a/src/tooling/docs-builder/GlobalCliOptions.cs +++ b/src/tooling/docs-builder/GlobalCliOptions.cs @@ -12,7 +12,7 @@ namespace Documentation.Builder; /// public class GlobalCliOptions { - /// -l,--log-level, Log verbosity level: trace, debug, information, warning, error, critical. Default: information + /// -l,--log-level, Minimum log level. Default: information public LogLevel LogLevel { get; set; } = LogLevel.Information; /// -c,--config-source, Override the configuration source: local, remote From be87f0c96e5e3798b92978485752b88f0fa66c4c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 19:24:08 +0200 Subject: [PATCH 08/27] feat(tooling): upgrade to Nullean.Argh 0.9.1; [AsParameters] DTO help docs from referenced projects PR #25 in 0.9.1 adds XML doc fallback for [AsParameters] DTO members in referenced assemblies. Enable GenerateDocumentationFile on Isolated, Assembler, and Configuration service projects so argh's generator can read property summaries from the produced .xml files. Result: docs-builder build --help, assembler build --help, assembler index --help etc. now show full descriptions for every [AsParameters] DTO property (IsolatedBuildOptions, AssemblerBuildOptions, ElasticsearchIndexOptions). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 +++--- .../Elastic.Documentation.Configuration.csproj | 2 ++ .../Elastic.Documentation.Assembler.csproj | 2 ++ .../Elastic.Documentation.Isolated.csproj | 2 ++ .../Elastic.Documentation.Isolated/IsolatedBuildOptions.cs | 3 +-- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index dd4533d3e2..4d39b78310 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index 5d907212d4..8796dc5fd7 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -7,6 +7,8 @@ $(NoWarn);CS0618 true + true + $(NoWarn);CS1591;CS1573;CS1572;CS1571;CS1570;CS1574 diff --git a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj index 27dfad62b3..cdfa8983db 100644 --- a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj +++ b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj @@ -5,6 +5,8 @@ enable enable true + true + $(NoWarn);CS1591;CS1573;CS1572;CS1571;CS1570;CS1574 diff --git a/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj b/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj index 5fb7df66b5..24607021b7 100644 --- a/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj +++ b/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj @@ -5,6 +5,8 @@ enable enable true + true + $(NoWarn);CS1591;CS1573;CS1572;CS1571;CS1570;CS1574 diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index 33d57e1bd4..bc3547c878 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -32,8 +32,7 @@ public record IsolatedBuildOptions /// /// Comma-separated list of exporters to run. - /// Values: Html, Elasticsearch, Configuration, LinkMetadata, DocumentationState, LLMText, Redirects. - /// Default: Html, Configuration, LinkMetadata, DocumentationState, Redirects. + /// Default: html, configuration, linkmetadata, documentationState, dedirects. /// // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + [CollectionSyntax] interaction public IReadOnlySet? Exporters { get; init; } From e7d31116dcb9520cd8f10e3e5487fa27e4eb1185 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 20:09:30 +0200 Subject: [PATCH 09/27] refactor(cli): use [Url] from DataAnnotations for URI fields; CanonicalBaseUrl as Uri? - IsolatedBuildOptions.CanonicalBaseUrl: string? -> Uri? with [Url] (argh maps [Url] on Uri? to [UriScheme("http","https")] constraint) - IsolatedBuildService.Build: remove manual Uri.TryCreate; use the already-validated Uri? from argh binding with ?? default fallback - ElasticsearchIndexOptions.Endpoint/ProxyAddress: [UriScheme("http","https")] -> [Url] for brevity (equivalent per argh generator) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ElasticsearchEndpointConfigurator.cs | 4 ++-- .../IsolatedBuildOptions.cs | 4 +++- .../IsolatedBuildService.cs | 9 ++------- src/tooling/docs-builder/GlobalCliOptions.cs | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs index 4b5ba1acfa..23e63e919c 100644 --- a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs @@ -19,7 +19,7 @@ public record ElasticsearchIndexOptions // --- endpoint --- /// -es,--endpoint, Elasticsearch endpoint URL. Falls back to env DOCUMENTATION_ELASTIC_URL. - [UriScheme("http", "https")] + [Url] public Uri? Endpoint { get; init; } /// API key for authentication. Falls back to env DOCUMENTATION_ELASTIC_APIKEY. @@ -70,7 +70,7 @@ public record ElasticsearchIndexOptions // --- proxy --- /// Route requests through this proxy URL. - [UriScheme("http", "https")] + [Url] public Uri? ProxyAddress { get; init; } /// Proxy server username. diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index bc3547c878..aa8d1a4126 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using Nullean.Argh; namespace Elastic.Documentation.Isolated; @@ -38,7 +39,8 @@ public record IsolatedBuildOptions public IReadOnlySet? Exporters { get; init; } /// Base URL written into <link rel=canonical> tags. - public string? CanonicalBaseUrl { get; init; } + [Url] + public Uri? CanonicalBaseUrl { get; init; } /// Skip OpenAPI spec generation for faster builds. public bool SkipApi { get; init; } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 2515538f6c..d4762fc90c 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -58,7 +58,7 @@ public async Task Build( var allowIndexing = options.AllowIndexing; var metadataOnly = options.MetadataOnly; var exporters = options.Exporters; - var canonicalBaseUrl = options.CanonicalBaseUrl; + var canonicalBaseUri = options.CanonicalBaseUrl; var skipOpenApi = options.SkipApi; var skipCrossLinks = options.SkipCrossLinks; @@ -74,7 +74,7 @@ public async Task Build( var runningOnCi = _env.IsRunningOnCI; BuildContext context; - Uri? canonicalBaseUri; + canonicalBaseUri ??= new Uri("https://docs-v3-preview.elastic.dev"); if (runningOnCi) { @@ -82,11 +82,6 @@ public async Task Build( force = true; } - if (canonicalBaseUrl is null) - canonicalBaseUri = new Uri("https://docs-v3-preview.elastic.dev"); - else if (!Uri.TryCreate(canonicalBaseUrl, UriKind.Absolute, out canonicalBaseUri)) - throw new ArgumentException($"The canonical base url '{canonicalBaseUrl}' is not a valid absolute uri"); - try { context = new BuildContext(collector, fileSystem, writeFileSystem ?? fileSystem, configurationContext, exporters, path, output) diff --git a/src/tooling/docs-builder/GlobalCliOptions.cs b/src/tooling/docs-builder/GlobalCliOptions.cs index fa3752aa8a..237c90f5e8 100644 --- a/src/tooling/docs-builder/GlobalCliOptions.cs +++ b/src/tooling/docs-builder/GlobalCliOptions.cs @@ -16,7 +16,7 @@ public class GlobalCliOptions public LogLevel LogLevel { get; set; } = LogLevel.Information; /// -c,--config-source, Override the configuration source: local, remote - //public ConfigurationSource? ConfigurationSource { get; set; } + public ConfigurationSource? ConfigSource { get; set; } /// Skip cloning private repositories public bool SkipPrivateRepositories { get; set; } From cb4bc5bb9d4561f96816fa236a9b557888a86051 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 21:16:28 +0200 Subject: [PATCH 10/27] refactor(cli): migrate file/directory path params to FileInfo and DirectoryInfo - ElasticsearchIndexOptions.CertificatePath: string? -> FileInfo? [FileExtensions("pem,der,crt,cer")] - IsolatedBuildOptions.Path, Output: string? -> DirectoryInfo? - Codex config params (all four commands): string -> FileInfo [FileExtensions("yml,yaml")] - Codex output params: string? -> DirectoryInfo? - DeployCommands: planFile -> FileInfo [json], @out -> FileInfo?, redirectsFile -> FileInfo? [json] - CodexUpdateRedirectsCommand.redirectsFile: string? -> FileInfo? [json] - NavigationCommands.ValidateLinkReference file: string? -> FileInfo? [json] - InboundLinkCommands.ValidateLinkReference file: string? -> FileInfo? [json] - BloomFilterCommands.Create builtDocsDir: string -> DirectoryInfo - AssemblerCommands.Serve path: string? -> DirectoryInfo? - ServeCommand path: string? -> DirectoryInfo? - ChangelogCommand.Init path/changelogDir/bundlesDir: string? -> DirectoryInfo? - ChangelogCommand config params (Add, Bundle, Remove, Render, GhRelease, Upload, EvaluatePr): string? -> FileInfo? [yml,yaml] - ChangelogCommand directory params: string? -> DirectoryInfo? - ChangelogCommand.BundleAmend bundlePath: string -> FileInfo [yml,yaml] - Help text: path/file/dir params now show as , , with extension hints Tilde expansion report (params needing future argh [ExpandTilde] attribute): changelog init path/changelogDir/bundlesDir, all config params, all directory params, assembler deploy planFile/@out, CertificatePath, build Path/Output. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ElasticsearchEndpointConfigurator.cs | 11 +-- .../IsolatedBuildOptions.cs | 4 +- .../IsolatedBuildService.cs | 4 +- .../IsolatedIndexService.cs | 2 +- .../Commands/Assembler/AssemblerCommands.cs | 4 +- .../Commands/Assembler/BloomFilterCommands.cs | 4 +- .../Commands/Assembler/DeployCommands.cs | 13 +-- .../Commands/Assembler/NavigationCommands.cs | 5 +- .../docs-builder/Commands/ChangelogCommand.cs | 79 +++++++++---------- .../Commands/Codex/CodexCommands.cs | 40 +++++----- .../Commands/Codex/CodexIndexCommand.cs | 9 +-- .../Codex/CodexUpdateRedirectsCommand.cs | 12 +-- .../Commands/InboundLinkCommands.cs | 5 +- .../Commands/IsolatedBuildCommand.cs | 4 +- .../docs-builder/Commands/ServeCommand.cs | 6 +- .../docs-builder/Http/InMemoryBuildState.cs | 2 +- 16 files changed, 105 insertions(+), 99 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs index 23e63e919c..be51d4e29b 100644 --- a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs @@ -88,7 +88,8 @@ public record ElasticsearchIndexOptions public string? CertificateFingerprint { get; init; } /// Path to a PEM or DER certificate file for SSL validation. - public string? CertificatePath { get; init; } + [FileExtensions(Extensions = "pem,der,crt,cer")] + public FileInfo? CertificatePath { get; init; } /// Set when the certificate is an intermediate CA rather than the root. public bool? CertificateNotRoot { get; init; } @@ -138,11 +139,11 @@ public static async Task ApplyAsync( cfg.ProxyUsername = options.ProxyUsername; if (options.DisableSslVerification.HasValue) cfg.DisableSslVerification = options.DisableSslVerification.Value; - if (!string.IsNullOrEmpty(options.CertificatePath)) + if (options.CertificatePath is not null) { - if (!fileSystem.File.Exists(options.CertificatePath)) - collector.EmitGlobalError($"'{options.CertificatePath}' does not exist"); - var bytes = await fileSystem.File.ReadAllBytesAsync(options.CertificatePath, ctx); + if (!fileSystem.File.Exists(options.CertificatePath.FullName)) + collector.EmitGlobalError($"'{options.CertificatePath.FullName}' does not exist"); + var bytes = await fileSystem.File.ReadAllBytesAsync(options.CertificatePath.FullName, ctx); cfg.Certificate = X509CertificateLoader.LoadCertificate(bytes); } if (options.CertificateNotRoot.HasValue) diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index aa8d1a4126..9ccafea544 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -11,10 +11,10 @@ namespace Elastic.Documentation.Isolated; public record IsolatedBuildOptions { /// -p, Root directory of the documentation source. Defaults to cwd/docs. - public string? Path { get; init; } + public DirectoryInfo? Path { get; init; } /// -o, Destination for generated HTML. Defaults to .artifacts/html. - public string? Output { get; init; } + public DirectoryInfo? Output { get; init; } /// URL path prefix prepended to every generated link. public string? PathPrefix { get; init; } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index d4762fc90c..bd75d5b656 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -50,8 +50,8 @@ public async Task Build( Cancel ctx = default ) { - var path = options.Path; - var output = options.Output; + var path = options.Path?.FullName; + var output = options.Output?.FullName; var pathPrefix = options.PathPrefix; var force = options.Force; var strict = options.Strict; diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs index f41a76173d..8f615612d2 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs @@ -35,7 +35,7 @@ public async Task Index( return await Build(collector, fileSystem, new IsolatedBuildOptions { - Path = path, + Path = path != null ? new DirectoryInfo(path) : null, MetadataOnly = true, Strict = false, Force = true, diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index 01e67bd764..bab8d4dd0a 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -157,9 +157,9 @@ static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state. /// Port to listen on. Default: 4000. /// Path to the built site. Defaults to .artifacts/docs/. [NoOptionsInjection] - public async Task Serve(int port = 4000, string? path = null, CancellationToken ct = default) + public async Task Serve(int port = 4000, DirectoryInfo? path = null, CancellationToken ct = default) { - var host = new StaticWebHost(port, path); + var host = new StaticWebHost(port, path?.FullName); await host.RunAsync(ct); await host.StopAsync(ct); await collector.StopAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs index 2d1fafd57e..2dd08eec0e 100644 --- a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs @@ -22,11 +22,11 @@ internal sealed class BloomFilterCommands(ILoggerFactory logFactory, IDiagnostic /// /// Path to the local legacy-docs repository checkout. [NoOptionsInjection] - public async Task Create(string builtDocsDir, CancellationToken ct = default) + public async Task Create(DirectoryInfo builtDocsDir, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var pagesProvider = new LocalPagesProvider(builtDocsDir); + var pagesProvider = new LocalPagesProvider(builtDocsDir.FullName); var legacyPageService = new LegacyPageService(logFactory); serviceInvoker.AddCommand(legacyPageService, pagesProvider, static (s, _, pagesProvider, _) => diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index ca688f7bfb..7913ef3f88 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using Actions.Core.Services; using Elastic.Documentation; using Elastic.Documentation.Assembler.Deploying; @@ -33,13 +34,13 @@ ICoreService githubActionsService /// Path to write the plan file. Defaults to stdout. /// Abort if the plan would delete more than this percentage of objects (0–100). [NoOptionsInjection] - public async Task Plan(string environment, string s3BucketName, string @out = "", float? deleteThreshold = null, CancellationToken ct = default) + public async Task Plan(string environment, string s3BucketName, FileInfo? @out = null, float? deleteThreshold = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new IncrementalDeployService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, FileSystemFactory.RealRead, FileSystemFactory.RealWrite); serviceInvoker.AddCommand(service, (environment, s3BucketName, @out, deleteThreshold), - static async (s, collector, state, ctx) => await s.Plan(collector, state.environment, state.s3BucketName, state.@out, state.deleteThreshold, ctx) + static async (s, collector, state, ctx) => await s.Plan(collector, state.environment, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, ctx) ); return await serviceInvoker.InvokeAsync(ct); } @@ -50,13 +51,13 @@ static async (s, collector, state, ctx) => await s.Plan(collector, state.environ /// S3 bucket to deploy to. /// Path to the plan file produced by assembler deploy plan. [NoOptionsInjection] - public async Task Apply(string environment, string s3BucketName, string planFile, CancellationToken ct = default) + public async Task Apply(string environment, string s3BucketName, [FileExtensions(Extensions = "json")] FileInfo planFile, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new IncrementalDeployService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, FileSystemFactory.RealRead, FileSystemFactory.RealWrite); serviceInvoker.AddCommand(service, (environment, s3BucketName, planFile), - static async (s, collector, state, ctx) => await s.Apply(collector, state.environment, state.s3BucketName, state.planFile, ctx) + static async (s, collector, state, ctx) => await s.Apply(collector, state.environment, state.s3BucketName, state.planFile.FullName, ctx) ); return await serviceInvoker.InvokeAsync(ct); } @@ -66,14 +67,14 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.enviro /// Named deployment target. /// Path to redirects.json. Defaults to .artifacts/docs/redirects.json. [NoOptionsInjection] - public async Task UpdateRedirects(string environment, string? redirectsFile = null, CancellationToken ct = default) + public async Task UpdateRedirects(string environment, [FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; var service = new DeployUpdateRedirectsService(logFactory, fs); serviceInvoker.AddCommand(service, (environment, redirectsFile), - static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile, ctx: ctx) + static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, ctx: ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index e04194b716..810832ac83 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Assembler.Navigation; @@ -35,11 +36,11 @@ public async Task Validate(CancellationToken ct = default) /// Check that no link in a local links.json conflicts with a path prefix defined in navigation.yml. /// Path to links.json. Defaults to .artifacts/docs/html/links.json. [NoOptionsInjection] - public async Task ValidateLinkReference([Argument] string? file = null, CancellationToken ct = default) + public async Task ValidateLinkReference([Argument, FileExtensions(Extensions = "json")] FileInfo? file = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); - serviceInvoker.AddCommand(service, file, static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file, ctx)); + serviceInvoker.AddCommand(service, file, static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file?.FullName, ctx)); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 50315c4edf..7373eb2773 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using System.Linq; using System.Text; @@ -56,14 +57,14 @@ IEnvironmentVariables environmentVariables /// GitHub repository name for seeding bundle defaults. Overrides the value inferred from git remote origin. [NoOptionsInjection] public Task Init( - string? path = null, - string? changelogDir = null, - string? bundlesDir = null, + DirectoryInfo? path = null, + DirectoryInfo? changelogDir = null, + DirectoryInfo? bundlesDir = null, string? owner = null, string? repo = null ) { - var rootPath = NormalizePath(path ?? "."); + var rootPath = path?.FullName ?? Path.GetFullPath("."); var rootDir = _fileSystem.DirectoryInfo.New(rootPath); IDirectoryInfo docsFolder; @@ -84,8 +85,8 @@ public Task Init( } var configPath = _fileSystem.Path.Join(docsFolder.FullName, "changelog.yml"); - var changelogPath = NormalizePath(changelogDir ?? "changelog"); - var bundlesPath = NormalizePath(bundlesDir ?? "releases"); + var changelogPath = changelogDir?.FullName ?? Path.GetFullPath("changelog"); + var bundlesPath = bundlesDir?.FullName ?? Path.GetFullPath("releases"); var useNonDefaultChangelogDir = changelogDir != null; var useNonDefaultBundlesDir = bundlesDir != null; @@ -237,7 +238,7 @@ public async Task Add( string? action = null, string[]? areas = null, bool concise = false, - string? config = null, + [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, string? description = null, bool noExtractReleaseNotes = false, bool noExtractIssues = false, @@ -288,7 +289,7 @@ public async Task Add( // Precedence: CLI option > bundle section in changelog.yml > built-in default. // This applies to --prs, --issues, and --release-version alike. var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config, ctx); + .LoadChangelogConfiguration(collector, config?.FullName, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; var resolvedOutput = !string.IsNullOrWhiteSpace(output) ? output : bundleConfig?.Bundle?.Directory; @@ -317,7 +318,7 @@ public async Task Add( { Repository = repoArg, Version = releaseVersion, - Config = config, + Config = config?.FullName, Output = resolvedOutput, StripTitlePrefix = stripTitlePrefixResolved, CreateBundle = false @@ -463,7 +464,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c FeatureId = featureId, Highlight = highlight, Output = resolvedOutput, - Config = config, + Config = config?.FullName, UsePrNumber = usePrNumber, UseIssueNumber = useIssueNumber, StripTitlePrefix = stripTitlePrefixResolved, @@ -513,8 +514,8 @@ public async Task Bundle( [Argument] string? profileArg = null, [Argument] string? profileReport = null, bool all = false, - string? config = null, - string? directory = null, + [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + DirectoryInfo? directory = null, string? description = null, string[]? hideFeatures = null, bool noReleaseDate = false, @@ -554,7 +555,7 @@ public async Task Bundle( { // Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic" var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config, ctx); + .LoadChangelogConfiguration(collector, config?.FullName, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; @@ -617,9 +618,9 @@ public async Task Bundle( forbidden.Add("--resolve / --no-resolve"); if (hideFeatures is { Length: > 0 }) forbidden.Add("--hide-features"); - if (!string.IsNullOrWhiteSpace(config)) + if (config != null) forbidden.Add("--config"); - if (!string.IsNullOrWhiteSpace(directory)) + if (directory != null) forbidden.Add("--directory"); if (!string.IsNullOrWhiteSpace(description)) forbidden.Add("--description"); @@ -773,7 +774,7 @@ public async Task Bundle( Output = processedOutput, Profile = profile, ProfileArgument = profileArg, - Config = config, + Config = config?.FullName, Description = description }; var planResult = await service.PlanBundleAsync(collector, planInput, releaseVersion != null, ctx); @@ -823,7 +824,7 @@ public async Task Bundle( var input = new BundleChangelogsArguments { - Directory = directory, + Directory = directory?.FullName, Output = processedOutput, All = all, InputProducts = inputProducts, @@ -837,7 +838,7 @@ public async Task Bundle( ProfileArgument = profileArg, ProfileReport = isProfileMode ? profileReport : null, Report = !isProfileMode ? report : null, - Config = config, + Config = config?.FullName, HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null, Description = description, ReleaseDate = releaseDate, @@ -879,9 +880,9 @@ public async Task Remove( [Argument] string? profileArg = null, [Argument] string? profileReport = null, bool all = false, - string? bundlesDir = null, - string? config = null, - string? directory = null, + DirectoryInfo? bundlesDir = null, + [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + DirectoryInfo? directory = null, bool dryRun = false, bool force = false, string[]? issues = null, @@ -913,7 +914,7 @@ public async Task Remove( // Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic" var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config, ctx); + .LoadChangelogConfiguration(collector, config?.FullName, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; @@ -1055,9 +1056,7 @@ public async Task Remove( // In profile mode, directory is derived from the changelog config (not from CLI). // In raw mode, pass null when --directory is not specified so ApplyConfigDefaults can consult // bundle.directory before falling back to CWD. - var resolvedDirectory = isProfileMode || string.IsNullOrWhiteSpace(directory) - ? null - : NormalizePath(directory); + var resolvedDirectory = isProfileMode ? null : directory?.FullName; var input = new ChangelogRemoveArguments { @@ -1069,9 +1068,9 @@ public async Task Remove( Owner = owner, Repo = repo, DryRun = dryRun, - BundlesDir = string.IsNullOrWhiteSpace(bundlesDir) ? null : NormalizePath(bundlesDir), + BundlesDir = bundlesDir?.FullName, Force = force, - Config = string.IsNullOrWhiteSpace(config) ? null : NormalizePath(config), + Config = config?.FullName, Profile = isProfileMode ? profile : null, ProfileArgument = isProfileMode ? profileArg : null, ProfileReport = isProfileMode ? profileReport : null, @@ -1097,7 +1096,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s [NoOptionsInjection] public async Task Render( string[]? input = null, - string? config = null, + [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, string? fileType = "markdown", string[]? hideFeatures = null, string? output = null, @@ -1136,7 +1135,7 @@ public async Task Render( Subsections = subsections, HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null, FileType = ft.Value, - Config = config + Config = config?.FullName }; serviceInvoker.AddCommand(service, renderInput, @@ -1160,7 +1159,7 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s public async Task GhRelease( [Argument] string repo, [Argument] string version = "latest", - string? config = null, + [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, string? description = null, string? output = null, string? releaseDate = null, @@ -1174,7 +1173,7 @@ public async Task GhRelease( // --output CLI > bundle.directory config > ./changelogs (service default) var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config, ctx); + .LoadChangelogConfiguration(collector, config?.FullName, ctx); var resolvedOutput = !string.IsNullOrWhiteSpace(output) ? output : bundleConfig?.Bundle?.Directory; IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); @@ -1195,7 +1194,7 @@ public async Task GhRelease( { Repository = repo, Version = version, - Config = config, + Config = config?.FullName, Output = resolvedOutput, StripTitlePrefix = stripTitlePrefixResolved, WarnOnTypeMismatch = warnOnTypeMismatch, @@ -1217,7 +1216,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c /// Optional: Copy the contents of each changelog file into the entries array. Use --no-resolve to explicitly turn off resolve (overrides inference from original bundle). [NoOptionsInjection] public async Task BundleAmend( - [Argument] string bundlePath, + [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo bundlePath, string[]? add = null, bool? resolve = null, CancellationToken ct = default @@ -1238,7 +1237,7 @@ public async Task BundleAmend( } // Normalize the bundle path - var normalizedBundlePath = NormalizePath(bundlePath); + var normalizedBundlePath = bundlePath.FullName; var normalizedAddFiles = ExpandCommaSeparated(add) .Select(NormalizePath) @@ -1283,7 +1282,7 @@ async static (s, collector, state, ctx) => await s.AmendBundle(collector, state, /// [NoOptionsInjection] public async Task EvaluatePr( - string config, + [FileExtensions(Extensions = "yml,yaml")] FileInfo config, string owner, string repo, int prNumber, @@ -1309,7 +1308,7 @@ public async Task EvaluatePr( var args = new EvaluatePrArguments { - Config = config, + Config = config.FullName, Owner = owner, Repo = repo, PrNumber = prNumber, @@ -1389,8 +1388,8 @@ public async Task Upload( string artifactType, string target, string s3BucketName = "", - string? config = null, - string? directory = null, + [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + DirectoryInfo? directory = null, CancellationToken ct = default ) { @@ -1413,8 +1412,8 @@ public async Task Upload( return 1; } - var resolvedDirectory = directory != null ? NormalizePath(directory) : null; - var resolvedConfig = config != null ? NormalizePath(config) : null; + var resolvedDirectory = directory != null ? directory?.FullName : null; + var resolvedConfig = config != null ? config?.FullName : null; await using var serviceInvoker = new ServiceInvoker(collector); var service = new ChangelogUploadService(logFactory, configurationContext); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 23421f8e79..148d83dc35 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using Actions.Core.Services; using Documentation.Builder.Http; @@ -48,23 +49,24 @@ IEnvironmentVariables environmentVariables [DefaultCommand] public async Task CloneAndBuild( GlobalCliOptions _, - [Argument] string config, + [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, bool strict = false, bool fetchLatest = false, bool assumeCloned = false, - string? output = null, + DirectoryInfo? output = null, bool serve = false, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; - var configPath = fs.Path.GetFullPath(config); - var configFile = fs.FileInfo.New(configPath); + + + var configFile = fs.FileInfo.New(config.FullName); if (!configFile.Exists) { - collector.EmitGlobalError($"Codex configuration file not found: {configPath}"); + collector.EmitGlobalError($"Codex configuration file not found: {config.FullName}"); return 1; } @@ -76,7 +78,7 @@ public async Task CloneAndBuild( return 1; } - var codexContext = new CodexContext(codexConfig, configFile, collector, fs, fs, null, output); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs, fs, null, output?.FullName); using var linkIndexReader = new GitLinkIndexReader(codexConfig.Environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -119,7 +121,7 @@ public async Task CloneAndBuild( /// Skip cloning; assume repositories are already on disk. [NoOptionsInjection] public async Task Clone( - [Argument] string config, + [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, bool strict = false, bool fetchLatest = false, bool assumeCloned = false, @@ -128,12 +130,13 @@ public async Task Clone( await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; - var configPath = fs.Path.GetFullPath(config); - var configFile = fs.FileInfo.New(configPath); + + + var configFile = fs.FileInfo.New(config.FullName); if (!configFile.Exists) { - collector.EmitGlobalError($"Codex configuration file not found: {configPath}"); + collector.EmitGlobalError($"Codex configuration file not found: {config.FullName}"); return 1; } @@ -166,20 +169,21 @@ public async Task Clone( /// Output directory. Defaults to .artifacts/codex/. [NoOptionsInjection] public async Task Build( - [Argument] string config, + [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, bool strict = false, - string? output = null, + DirectoryInfo? output = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; - var configPath = fs.Path.GetFullPath(config); - var configFile = fs.FileInfo.New(configPath); + + + var configFile = fs.FileInfo.New(config.FullName); if (!configFile.Exists) { - collector.EmitGlobalError($"Codex configuration file not found: {configPath}"); + collector.EmitGlobalError($"Codex configuration file not found: {config.FullName}"); return 1; } @@ -191,7 +195,7 @@ public async Task Build( return 1; } - var codexContext = new CodexContext(codexConfig, configFile, collector, fs, fs, null, output); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs, fs, null, output?.FullName); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); if (cloneResult == null || cloneResult.Checkouts.Count == 0) @@ -217,10 +221,10 @@ public async Task Build( /// Port to listen on. Default: 4000. /// Path to the portal output. Defaults to .artifacts/codex/docs/. [NoOptionsInjection] - public async Task Serve(int port = 4000, string? path = null, CancellationToken ct = default) + public async Task Serve(int port = 4000, DirectoryInfo? path = null, CancellationToken ct = default) { var fs = FileSystemFactory.RealRead; - var servePath = path ?? fs.Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); + var servePath = path?.FullName ?? fs.Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); var host = new StaticWebHost(port, servePath); await host.RunAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 16b3691bcf..36ef8b5207 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using Actions.Core.Services; @@ -36,20 +37,18 @@ IEnvironmentVariables environmentVariables /// Path to the codex.yml configuration file. public async Task Index( GlobalCliOptions _, - [Argument] string config, + [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, [AsParameters] ElasticsearchIndexOptions es, CancellationToken ct = default ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; - - var configPath = fs.Path.GetFullPath(config); - var configFile = fs.FileInfo.New(configPath); + var configFile = fs.FileInfo.New(config.FullName); if (!configFile.Exists) { - collector.EmitGlobalError($"Codex configuration file not found: {configPath}"); + collector.EmitGlobalError($"Codex configuration file not found: {config.FullName}"); return 1; } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index 1afa56733f..44e6976a4e 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Assembler.Deploying; @@ -27,20 +28,19 @@ ILoggerFactory logFactory /// Path to redirects.json. Defaults to .artifacts/codex/docs/redirects.json. public async Task UpdateRedirects( GlobalCliOptions _, - [Argument] string config, + [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, string? environment = null, - string? redirectsFile = null, + [FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; - var configPath = fs.Path.GetFullPath(config); - var configFile = fs.FileInfo.New(configPath); + var configFile = fs.FileInfo.New(config.FullName); if (!configFile.Exists) { - collector.EmitGlobalError($"Codex configuration file not found: {configPath}"); + collector.EmitGlobalError($"Codex configuration file not found: {config.FullName}"); return 1; } @@ -52,7 +52,7 @@ public async Task UpdateRedirects( var service = new DeployUpdateRedirectsService(logFactory, fs); serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), - static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile, state.kvsNamePrefix, state.defaultRedirectsFile, c) + static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, c) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index e93f5239c7..88b1bcc7ca 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; @@ -55,11 +56,11 @@ static async (s, collector, state, ctx) => await s.CheckRepository(collector, st /// Path to links.json. Defaults to .artifacts/docs/html/links.json. /// -p, Root of the documentation source. Defaults to cwd. [NoOptionsInjection] - public async Task ValidateLinkReference(string? file = null, string? path = null, CancellationToken ct = default) + public async Task ValidateLinkReference([FileExtensions(Extensions = "json")] FileInfo? file = null, string? path = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); serviceInvoker.AddCommand(_linkIndexService, (file, path), - static async (s, collector, state, ctx) => await s.CheckWithLocalLinksJson(collector, state.file, state.path, ctx) + static async (s, collector, state, ctx) => await s.CheckWithLocalLinksJson(collector, state.file?.FullName, state.path, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index 369c68f563..b39cdcb43e 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -39,8 +39,8 @@ public async Task Build( await using var serviceInvoker = new ServiceInvoker(collector); var service = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(options.Path); - var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(options.Path, options.Output); + var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(options.Path?.FullName); + var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(options.Path?.FullName, options.Output?.FullName); var strictCommand = service.IsStrict(options.Strict); serviceInvoker.AddCommand(service, (options, readFs, writeFs), strictCommand, diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index a9af5bec89..d390e0bb94 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -17,13 +17,13 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont /// Serve a documentation folder at http://localhost:3000 with live reload. /// File-system changes are reflected without restarting the server. - /// -p, Path to serve. Defaults to the cwd/docs folder + /// -p, Documentation source directory. Defaults to the cwd/docs folder. /// Port to serve the documentation. Default: 3000 /// Special flag for dotnet watch optimizations during development [CommandName("serve")] - public async Task Serve(GlobalCliOptions _, string? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) + public async Task Serve(GlobalCliOptions _, DirectoryInfo? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) { - var host = new DocumentationWebHost(logFactory, path, port, FileSystemFactory.RealGitRootForPath(path), FileSystemFactory.InMemory(), configurationContext, watch); + var host = new DocumentationWebHost(logFactory, path?.FullName, port, FileSystemFactory.RealGitRootForPath(path?.FullName), FileSystemFactory.InMemory(), configurationContext, watch); await host.RunAsync(ct); _logger.LogInformation("Find your documentation at http://localhost:{Port}/{Path}", port, host.GeneratorState.Generator.DocumentationSet.FirstInterestingUrl.TrimStart('/') diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index 7a71096022..628b752b67 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -179,7 +179,7 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) readFs, new IsolatedBuildOptions { - Path = sourcePath, + Path = new DirectoryInfo(sourcePath), Force = true, Strict = false, AllowIndexing = false, From ad18d2b678a3ffe56a88454144d2b14ad58e8b7d Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 21:32:28 +0200 Subject: [PATCH 11/27] feat(tooling): upgrade to Nullean.Argh 0.11.0; add [Existing], [RejectSymbolicLinks], [ExpandUserProfile] - Bump Nullean.Argh, Nullean.Argh.Hosting, Nullean.Argh.Interfaces to 0.11.0 - [Existing] on all input FileInfo/DirectoryInfo params that must be present: CertificatePath, all codex config files, changelog config/bundlePath, deploy planFile/redirectsFile, navigation/inbound-links file, bloom-filter builtDocsDir, serve path params, IsolatedBuildOptions.Path (source dir must exist) - [RejectSymbolicLinks] on every FileInfo/DirectoryInfo param and property - [ExpandUserProfile] on every FileInfo/DirectoryInfo param and property (fixes ~/path expansion for all path arguments) - IsolatedBuildOptions.Output intentionally omitted from [Existing] (created by build) - Codex and changelog output directories omitted from [Existing] (created by commands) Help output now shows: [existing] [no symlinks] [expand ~ profile] on appropriate args. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 ++-- .../ElasticsearchEndpointConfigurator.cs | 2 +- .../IsolatedBuildOptions.cs | 2 ++ .../Commands/Assembler/AssemblerCommands.cs | 2 +- .../Commands/Assembler/BloomFilterCommands.cs | 2 +- .../Commands/Assembler/DeployCommands.cs | 6 ++-- .../Commands/Assembler/NavigationCommands.cs | 2 +- .../docs-builder/Commands/ChangelogCommand.cs | 28 +++++++++---------- .../Commands/Codex/CodexCommands.cs | 12 ++++---- .../Commands/Codex/CodexIndexCommand.cs | 2 +- .../Codex/CodexUpdateRedirectsCommand.cs | 4 +-- .../Commands/InboundLinkCommands.cs | 2 +- .../docs-builder/Commands/ServeCommand.cs | 2 +- 13 files changed, 37 insertions(+), 35 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4d39b78310..b08038478d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + diff --git a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs index be51d4e29b..d2761cce7f 100644 --- a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs @@ -88,7 +88,7 @@ public record ElasticsearchIndexOptions public string? CertificateFingerprint { get; init; } /// Path to a PEM or DER certificate file for SSL validation. - [FileExtensions(Extensions = "pem,der,crt,cer")] + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "pem,der,crt,cer")] public FileInfo? CertificatePath { get; init; } /// Set when the certificate is an intermediate CA rather than the root. diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index 9ccafea544..1bc99a181b 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -11,9 +11,11 @@ namespace Elastic.Documentation.Isolated; public record IsolatedBuildOptions { /// -p, Root directory of the documentation source. Defaults to cwd/docs. + [Existing, ExpandUserProfile, RejectSymbolicLinks] public DirectoryInfo? Path { get; init; } /// -o, Destination for generated HTML. Defaults to .artifacts/html. + [ExpandUserProfile, RejectSymbolicLinks] public DirectoryInfo? Output { get; init; } /// URL path prefix prepended to every generated link. diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index bab8d4dd0a..6023aeccf0 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -157,7 +157,7 @@ static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state. /// Port to listen on. Default: 4000. /// Path to the built site. Defaults to .artifacts/docs/. [NoOptionsInjection] - public async Task Serve(int port = 4000, DirectoryInfo? path = null, CancellationToken ct = default) + public async Task Serve(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default) { var host = new StaticWebHost(port, path?.FullName); await host.RunAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs index 2dd08eec0e..5170889fc0 100644 --- a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs @@ -22,7 +22,7 @@ internal sealed class BloomFilterCommands(ILoggerFactory logFactory, IDiagnostic /// /// Path to the local legacy-docs repository checkout. [NoOptionsInjection] - public async Task Create(DirectoryInfo builtDocsDir, CancellationToken ct = default) + public async Task Create([Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo builtDocsDir, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index 7913ef3f88..7e59432288 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -34,7 +34,7 @@ ICoreService githubActionsService /// Path to write the plan file. Defaults to stdout. /// Abort if the plan would delete more than this percentage of objects (0–100). [NoOptionsInjection] - public async Task Plan(string environment, string s3BucketName, FileInfo? @out = null, float? deleteThreshold = null, CancellationToken ct = default) + public async Task Plan(string environment, string s3BucketName, [ExpandUserProfile, RejectSymbolicLinks] FileInfo? @out = null, float? deleteThreshold = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -51,7 +51,7 @@ static async (s, collector, state, ctx) => await s.Plan(collector, state.environ /// S3 bucket to deploy to. /// Path to the plan file produced by assembler deploy plan. [NoOptionsInjection] - public async Task Apply(string environment, string s3BucketName, [FileExtensions(Extensions = "json")] FileInfo planFile, CancellationToken ct = default) + public async Task Apply(string environment, string s3BucketName, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo planFile, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -67,7 +67,7 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.enviro /// Named deployment target. /// Path to redirects.json. Defaults to .artifacts/docs/redirects.json. [NoOptionsInjection] - public async Task UpdateRedirects(string environment, [FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default) + public async Task UpdateRedirects(string environment, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index 810832ac83..7f1f03820e 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -36,7 +36,7 @@ public async Task Validate(CancellationToken ct = default) /// Check that no link in a local links.json conflicts with a path prefix defined in navigation.yml. /// Path to links.json. Defaults to .artifacts/docs/html/links.json. [NoOptionsInjection] - public async Task ValidateLinkReference([Argument, FileExtensions(Extensions = "json")] FileInfo? file = null, CancellationToken ct = default) + public async Task ValidateLinkReference([Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 7373eb2773..9a76a2971c 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -57,9 +57,9 @@ IEnvironmentVariables environmentVariables /// GitHub repository name for seeding bundle defaults. Overrides the value inferred from git remote origin. [NoOptionsInjection] public Task Init( - DirectoryInfo? path = null, - DirectoryInfo? changelogDir = null, - DirectoryInfo? bundlesDir = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? changelogDir = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? bundlesDir = null, string? owner = null, string? repo = null ) @@ -238,7 +238,7 @@ public async Task Add( string? action = null, string[]? areas = null, bool concise = false, - [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, string? description = null, bool noExtractReleaseNotes = false, bool noExtractIssues = false, @@ -514,8 +514,8 @@ public async Task Bundle( [Argument] string? profileArg = null, [Argument] string? profileReport = null, bool all = false, - [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, - DirectoryInfo? directory = null, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? directory = null, string? description = null, string[]? hideFeatures = null, bool noReleaseDate = false, @@ -880,9 +880,9 @@ public async Task Remove( [Argument] string? profileArg = null, [Argument] string? profileReport = null, bool all = false, - DirectoryInfo? bundlesDir = null, - [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, - DirectoryInfo? directory = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? bundlesDir = null, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? directory = null, bool dryRun = false, bool force = false, string[]? issues = null, @@ -1096,7 +1096,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s [NoOptionsInjection] public async Task Render( string[]? input = null, - [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, string? fileType = "markdown", string[]? hideFeatures = null, string? output = null, @@ -1159,7 +1159,7 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s public async Task GhRelease( [Argument] string repo, [Argument] string version = "latest", - [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, string? description = null, string? output = null, string? releaseDate = null, @@ -1216,7 +1216,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c /// Optional: Copy the contents of each changelog file into the entries array. Use --no-resolve to explicitly turn off resolve (overrides inference from original bundle). [NoOptionsInjection] public async Task BundleAmend( - [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo bundlePath, + [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo bundlePath, string[]? add = null, bool? resolve = null, CancellationToken ct = default @@ -1388,8 +1388,8 @@ public async Task Upload( string artifactType, string target, string s3BucketName = "", - [FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, - DirectoryInfo? directory = null, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? directory = null, CancellationToken ct = default ) { diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 148d83dc35..fd5ab16bca 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -49,11 +49,11 @@ IEnvironmentVariables environmentVariables [DefaultCommand] public async Task CloneAndBuild( GlobalCliOptions _, - [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, + [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, bool strict = false, bool fetchLatest = false, bool assumeCloned = false, - DirectoryInfo? output = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? output = null, bool serve = false, CancellationToken ct = default) { @@ -121,7 +121,7 @@ public async Task CloneAndBuild( /// Skip cloning; assume repositories are already on disk. [NoOptionsInjection] public async Task Clone( - [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, + [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, bool strict = false, bool fetchLatest = false, bool assumeCloned = false, @@ -169,9 +169,9 @@ public async Task Clone( /// Output directory. Defaults to .artifacts/codex/. [NoOptionsInjection] public async Task Build( - [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, + [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, bool strict = false, - DirectoryInfo? output = null, + [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? output = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -221,7 +221,7 @@ public async Task Build( /// Port to listen on. Default: 4000. /// Path to the portal output. Defaults to .artifacts/codex/docs/. [NoOptionsInjection] - public async Task Serve(int port = 4000, DirectoryInfo? path = null, CancellationToken ct = default) + public async Task Serve(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default) { var fs = FileSystemFactory.RealRead; var servePath = path?.FullName ?? fs.Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 36ef8b5207..3e14db4f90 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -37,7 +37,7 @@ IEnvironmentVariables environmentVariables /// Path to the codex.yml configuration file. public async Task Index( GlobalCliOptions _, - [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, + [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, [AsParameters] ElasticsearchIndexOptions es, CancellationToken ct = default ) diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index 44e6976a4e..5795bd54a8 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -28,9 +28,9 @@ ILoggerFactory logFactory /// Path to redirects.json. Defaults to .artifacts/codex/docs/redirects.json. public async Task UpdateRedirects( GlobalCliOptions _, - [Argument, FileExtensions(Extensions = "yml,yaml")] FileInfo config, + [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, string? environment = null, - [FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index 88b1bcc7ca..e208eea64f 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -56,7 +56,7 @@ static async (s, collector, state, ctx) => await s.CheckRepository(collector, st /// Path to links.json. Defaults to .artifacts/docs/html/links.json. /// -p, Root of the documentation source. Defaults to cwd. [NoOptionsInjection] - public async Task ValidateLinkReference([FileExtensions(Extensions = "json")] FileInfo? file = null, string? path = null, CancellationToken ct = default) + public async Task ValidateLinkReference([Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, string? path = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); serviceInvoker.AddCommand(_linkIndexService, (file, path), diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index d390e0bb94..10f55ed6ec 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -21,7 +21,7 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont /// Port to serve the documentation. Default: 3000 /// Special flag for dotnet watch optimizations during development [CommandName("serve")] - public async Task Serve(GlobalCliOptions _, DirectoryInfo? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) + public async Task Serve(GlobalCliOptions _, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) { var host = new DocumentationWebHost(logFactory, path?.FullName, port, FileSystemFactory.RealGitRootForPath(path?.FullName), FileSystemFactory.InMemory(), configurationContext, watch); await host.RunAsync(ct); From d75f096ae71a0092f4a7d40f56bfac739576d65c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 29 Apr 2026 08:55:39 +0200 Subject: [PATCH 12/27] chore: upgrade Nullean.Argh to 0.12.0 Fixes: - PR #28: DTO XML summaries now render in --help; global options show short flag first (e.g. -l, --log-level) - PR #29: enum choices listed lowercase in help (html, elasticsearch, ...) - PR #30: [Existing] on optional nullable FileInfo?/DirectoryInfo? no longer throws ArgumentNullException when the flag is omitted - PR #31: MapAndRootAlias root help and leading flag handling fixed Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b08038478d..7a0ffd8b62 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + @@ -88,7 +88,7 @@ - + From ff01791977f5bf17689a58e7a32206b1eb20ec47 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 29 Apr 2026 09:47:10 +0200 Subject: [PATCH 13/27] fix(services): guard exporter default against empty set from argh [AsParameters] Argh 0.12.0 correctly handles IReadOnlySet in [AsParameters] DTOs but initialises the collection to an empty HashSet (not null) when no --exporters flags are supplied. The previous `??=` null-coalesce never fired on an empty set, so IsolatedBuildService and AssemblerBuildService ran with zero exporters (producing no output, no links.json). Replace `??=` with an explicit count-based guard in both services. Also enable [CollectionSyntax(Separator=",")] on IsolatedBuildOptions.Exporters now that the [AsParameters] + [CollectionSyntax] generator bug is fixed in 0.12.0. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Building/AssemblerBuildService.cs | 3 ++- .../Elastic.Documentation.Isolated/IsolatedBuildOptions.cs | 2 +- .../Elastic.Documentation.Isolated/IsolatedBuildService.cs | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index bc9615e18d..6e53fd5b36 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -47,7 +47,8 @@ Cancel ctx collector.NoHints = !showHints.GetValueOrDefault(false); strict ??= false; - exporters ??= metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; + if (exporters is not { Count: > 0 }) + exporters = metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; // ensure we never generate a documentation state for assembler builds if (exporters.Contains(Exporter.DocumentationState)) exporters = new HashSet(exporters.Except([Exporter.DocumentationState])); diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index 1bc99a181b..8d39f5d60a 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -37,7 +37,7 @@ public record IsolatedBuildOptions /// Comma-separated list of exporters to run. /// Default: html, configuration, linkmetadata, documentationState, dedirects. /// - // TODO: add [CollectionSyntax(Separator=",")] once argh fixes [AsParameters] + [CollectionSyntax] interaction + [CollectionSyntax(Separator = ",")] public IReadOnlySet? Exporters { get; init; } /// Base URL written into <link rel=canonical> tags. diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index bd75d5b656..60bdc9544e 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -67,7 +67,10 @@ public async Task Build( if (bool.TryParse(githubActionsService.GetInput("metadata-only"), out var metaValue) && metaValue) metadataOnly ??= metaValue; - exporters ??= metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; + // Argh initialises IReadOnlySet? in [AsParameters] DTOs to an empty set (not null) when the + // flag is omitted, so guard against both null and empty. + if (exporters is not { Count: > 0 }) + exporters = metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; pathPrefix ??= githubActionsService.GetInput("prefix"); From 1ade848d0fd7d7681c6dcda216457ec0430e9ff7 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 29 Apr 2026 10:46:28 +0200 Subject: [PATCH 14/27] fix: pin to Nullean.Argh 0.12.0; 0.12.1+ introduces CS8600 in generated code 0.12.1 (PR #33) fixed IReadOnlySet? defaulting to null when omitted in [AsParameters] DTOs, but the generator emits the nullable null-return into a non-nullable typed local variable (CS8600 / missing ? on the declaration), making the project unbuildable. 0.12.2 does not fix this. Pin to 0.12.0 and keep the is not { Count: > 0 } guard as a workaround until the generator bug is resolved upstream. Remove [CollectionSyntax] comments from DTOs (they were only relevant to the broken [AsParameters]+[CollectionSyntax] combination which is now fixed in 0.12.0 anyway). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Building/AssemblerBuildOptions.cs | 3 ++- .../Building/AssemblerBuildService.cs | 1 + .../Elastic.Documentation.Isolated/IsolatedBuildOptions.cs | 3 ++- .../Elastic.Documentation.Isolated/IsolatedBuildService.cs | 4 ++-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs index 1740954f5c..3ad886533a 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs @@ -27,7 +27,8 @@ public record AssemblerBuildOptions /// Values: Html, Elasticsearch, Configuration, LinkMetadata, DocumentationState, LLMText, Redirects. /// Default: Html, Configuration, LinkMetadata, DocumentationState, Redirects. /// - [CollectionSyntax(Separator = ",")] + // [CollectionSyntax(Separator=",")] omitted: 0.12.2 generator bug — null-return for empty [AsParameters] + // collection declared with non-nullable type (CS8600). Re-enable when fixed upstream. public IReadOnlySet? Exporters { get; init; } /// Skip the build step when .artifacts/docs/index.html already exists. Intended for test scenarios only. diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 6e53fd5b36..6632a1533a 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -47,6 +47,7 @@ Cancel ctx collector.NoHints = !showHints.GetValueOrDefault(false); strict ??= false; + // See IsolatedBuildService — same argh 0.12.0 empty-set workaround. if (exporters is not { Count: > 0 }) exporters = metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; // ensure we never generate a documentation state for assembler builds diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index 8d39f5d60a..a9a639f3ae 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -37,7 +37,8 @@ public record IsolatedBuildOptions /// Comma-separated list of exporters to run. /// Default: html, configuration, linkmetadata, documentationState, dedirects. /// - [CollectionSyntax(Separator = ",")] + // [CollectionSyntax(Separator=",")] omitted: 0.12.2 generator bug — null-return for empty [AsParameters] + // collection declared with non-nullable type (CS8600). Re-enable when fixed upstream. public IReadOnlySet? Exporters { get; init; } /// Base URL written into <link rel=canonical> tags. diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 60bdc9544e..8a4e66ff6a 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -67,8 +67,8 @@ public async Task Build( if (bool.TryParse(githubActionsService.GetInput("metadata-only"), out var metaValue) && metaValue) metadataOnly ??= metaValue; - // Argh initialises IReadOnlySet? in [AsParameters] DTOs to an empty set (not null) when the - // flag is omitted, so guard against both null and empty. + // Argh 0.12.0 initialises IReadOnlySet? in [AsParameters] DTOs to an empty set (not null) when the + // flag is omitted; 0.12.1+ fixes this but introduces CS8600. Guard against both null and empty for now. if (exporters is not { Count: > 0 }) exporters = metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; From 2e2cc27e534abe899674452a9b9328413dc958d9 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 29 Apr 2026 11:38:55 +0200 Subject: [PATCH 15/27] chore: upgrade to Nullean.Argh 0.12.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the CS8600 generator bug introduced in 0.12.1 where IReadOnlySet? in [AsParameters] DTOs was emitted with a non-nullable local type. Remove the is not { Count: > 0 } workaround — ??= is correct again. Re-enable [CollectionSyntax(Separator=",")] on IsolatedBuildOptions and AssemblerBuildOptions. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 8 ++++---- .../Building/AssemblerBuildOptions.cs | 3 +-- .../Building/AssemblerBuildService.cs | 4 +--- .../IsolatedBuildOptions.cs | 3 +-- .../IsolatedBuildService.cs | 5 +---- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7a0ffd8b62..8b72a60ae0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + @@ -88,7 +88,7 @@ - + diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs index 3ad886533a..1740954f5c 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildOptions.cs @@ -27,8 +27,7 @@ public record AssemblerBuildOptions /// Values: Html, Elasticsearch, Configuration, LinkMetadata, DocumentationState, LLMText, Redirects. /// Default: Html, Configuration, LinkMetadata, DocumentationState, Redirects. /// - // [CollectionSyntax(Separator=",")] omitted: 0.12.2 generator bug — null-return for empty [AsParameters] - // collection declared with non-nullable type (CS8600). Re-enable when fixed upstream. + [CollectionSyntax(Separator = ",")] public IReadOnlySet? Exporters { get; init; } /// Skip the build step when .artifacts/docs/index.html already exists. Intended for test scenarios only. diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 6632a1533a..bc9615e18d 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -47,9 +47,7 @@ Cancel ctx collector.NoHints = !showHints.GetValueOrDefault(false); strict ??= false; - // See IsolatedBuildService — same argh 0.12.0 empty-set workaround. - if (exporters is not { Count: > 0 }) - exporters = metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; + exporters ??= metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; // ensure we never generate a documentation state for assembler builds if (exporters.Contains(Exporter.DocumentationState)) exporters = new HashSet(exporters.Except([Exporter.DocumentationState])); diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs index a9a639f3ae..8d39f5d60a 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildOptions.cs @@ -37,8 +37,7 @@ public record IsolatedBuildOptions /// Comma-separated list of exporters to run. /// Default: html, configuration, linkmetadata, documentationState, dedirects. /// - // [CollectionSyntax(Separator=",")] omitted: 0.12.2 generator bug — null-return for empty [AsParameters] - // collection declared with non-nullable type (CS8600). Re-enable when fixed upstream. + [CollectionSyntax(Separator = ",")] public IReadOnlySet? Exporters { get; init; } /// Base URL written into <link rel=canonical> tags. diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 8a4e66ff6a..bd75d5b656 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -67,10 +67,7 @@ public async Task Build( if (bool.TryParse(githubActionsService.GetInput("metadata-only"), out var metaValue) && metaValue) metadataOnly ??= metaValue; - // Argh 0.12.0 initialises IReadOnlySet? in [AsParameters] DTOs to an empty set (not null) when the - // flag is omitted; 0.12.1+ fixes this but introduces CS8600. Guard against both null and empty for now. - if (exporters is not { Count: > 0 }) - exporters = metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; + exporters ??= metadataOnly.GetValueOrDefault(false) ? ExportOptions.MetadataOnly : ExportOptions.Default; pathPrefix ??= githubActionsService.GetInput("prefix"); From 0f6ed2859fc44b15c96e94a2496c1b62e73b1203 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 29 Apr 2026 13:02:13 +0200 Subject: [PATCH 16/27] fix: correct import ordering in ChangelogCommand.cs (dotnet format) --- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 9a76a2971c..cb980d3336 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -10,7 +10,6 @@ using Actions.Core.Services; using Documentation.Builder.Arguments; using Elastic.Changelog; -using Nullean.Argh; using Elastic.Changelog.Bundling; using Elastic.Changelog.Configuration; using Elastic.Changelog.Creation; @@ -24,6 +23,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.Argh; namespace Documentation.Builder.Commands; From 2c70eb8beb6ba9051ea77f959038f6b88f628fdf Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 29 Apr 2026 14:34:46 +0200 Subject: [PATCH 17/27] chore: upgrade Nullean.Argh to 0.12.4 (fixes global bool short options in help, AGH0033) --- Directory.Packages.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8b72a60ae0..b769f53205 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + @@ -88,7 +88,7 @@ - + From f01c04ba8c86ff02ed4446d54f91fb3ae4720b13 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 29 Apr 2026 16:02:01 +0200 Subject: [PATCH 18/27] chore: upgrade Nullean.Argh to 0.12.5 (fixes global short aliases after subcommand) --- Directory.Packages.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b769f53205..1f97d42273 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + @@ -88,7 +88,7 @@ - + From 67f453533372424af3e3d2e712d0e43fd67ed8e0 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 30 Apr 2026 14:26:54 +0200 Subject: [PATCH 19/27] refactor: remove IsMcpMode dead code and simplify AddElasticDocumentationLogging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsMcpMode always returned false (no mcp command registered in argh); remove it and the noConsole parameter it drove. GlobalCommandLine.cs is now minimal startup plumbing only — ScanArgs + GlobalCliArgs record. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../AppDefaultsExtensions.cs | 12 ++++-------- src/Elastic.Documentation/GlobalCommandLine.cs | 14 +++++++------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index dbe719144f..ebce76ee14 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -31,10 +31,9 @@ public static TBuilder AddDocumentationServiceDefaults(this TBuilder b where TBuilder : IHostApplicationBuilder { var cliArgs = GlobalCli.ScanArgs(args); - var isMcp = GlobalCli.IsMcpMode(args); var services = builder.Services; - _ = services.AddElasticDocumentationLogging(cliArgs.LogLevel, noConsole: isMcp); + _ = services.AddElasticDocumentationLogging(cliArgs.LogLevel); _ = services .AddConfigurationFileProvider(cliArgs.SkipPrivateRepositories, cliArgs.ConfigurationSource, (s, p) => { @@ -55,17 +54,14 @@ public static TBuilder AddDocumentationServiceDefaults(this TBuilder b return builder.AddServiceDefaults(); } - public static TServiceCollection AddElasticDocumentationLogging(this TServiceCollection services, LogLevel logLevel, bool noConsole = false) + public static TServiceCollection AddElasticDocumentationLogging(this TServiceCollection services, LogLevel logLevel) where TServiceCollection : IServiceCollection { _ = services.AddLogging(x => { _ = x.ClearProviders().SetMinimumLevel(logLevel); - if (!noConsole) - { - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - _ = x.AddConsole(c => c.FormatterName = "condensed"); - } + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + _ = x.AddConsole(c => c.FormatterName = "condensed"); }); return services; } diff --git a/src/Elastic.Documentation/GlobalCommandLine.cs b/src/Elastic.Documentation/GlobalCommandLine.cs index 3366e256f2..e5aa42060e 100644 --- a/src/Elastic.Documentation/GlobalCommandLine.cs +++ b/src/Elastic.Documentation/GlobalCommandLine.cs @@ -6,12 +6,15 @@ namespace Elastic.Documentation; -/// Early-parse utilities for use before the DI host is built. +/// +/// Early-parse utilities for startup DI setup in AppDefaultsExtensions before the argh host builds. +/// The authoritative CLI options for docs-builder itself live in GlobalCliOptions (docs-builder project). +/// public static class GlobalCli { /// - /// Scans for known startup flags without modifying the array. - /// Used for pre-host setup before argh routing runs. + /// Scans for startup flags without modifying the array. + /// Used by AppDefaultsExtensions before argh routing runs. /// public static GlobalCliArgs ScanArgs(string[] args) { @@ -32,9 +35,6 @@ public static GlobalCliArgs ScanArgs(string[] args) return options; } - /// Returns when the first non-flag argument is mcp. - public static bool IsMcpMode(string[] args) => args.Length > 0 && args[0] == "mcp"; - private static LogLevel ParseLogLevel(string? logLevel) => logLevel switch { "trace" => LogLevel.Trace, @@ -48,7 +48,7 @@ public static GlobalCliArgs ScanArgs(string[] args) }; } -/// Startup args parsed before the DI host builds (not injected into commands). +/// Startup args scanned before the DI host builds. Used by AppDefaultsExtensions. public record GlobalCliArgs { public LogLevel LogLevel { get; init; } = LogLevel.Information; From 19bdd9c0a5f05361d73aa9904f5fd67ddf53db1c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 4 May 2026 21:26:00 +0200 Subject: [PATCH 20/27] refactor: promote GlobalCliOptions to Elastic.Documentation, remove GlobalCliArgs GlobalCliArgs and GlobalCommandLine.cs were a workaround so AppDefaultsExtensions (ServiceDefaults) could scan args before the argh host builds without referencing docs-builder. Now GlobalCliOptions lives in Elastic.Documentation as a plain class, docs-builder calls TryParseArgh before host construction and passes the result to AddDocumentationServiceDefaults, and all manual arg scanning + ParseLogLevel are gone. Also fixes from inline review: certificate loading short-circuits on missing file, BootstrapTimeout stored as TimeSpan, changelog default dirs resolve relative to docsFolder, Ctrl+C sets args.Cancel, update check wrapped in try/catch, HttpClient disposed with using. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../DocumentationEndpoints.cs | 2 +- .../ElasticsearchEndpointConfigurator.cs | 11 +++- .../AppDefaultsExtensions.cs | 17 ++---- .../GlobalCliOptions.cs | 3 +- .../GlobalCommandLine.cs | 57 ------------------- .../Elastic.Documentation.Api.App/Program.cs | 2 +- .../Program.cs | 2 +- .../Assembler/ContentSourceCommands.cs | 2 + .../docs-builder/Commands/ChangelogCommand.cs | 4 +- .../Middleware/CatchExceptionMiddleware.cs | 4 +- .../Middleware/CheckForUpdatesMiddleware.cs | 38 +++++++------ src/tooling/docs-builder/Program.cs | 3 +- 12 files changed, 47 insertions(+), 98 deletions(-) rename src/{tooling/docs-builder => Elastic.Documentation}/GlobalCliOptions.cs (92%) delete mode 100644 src/Elastic.Documentation/GlobalCommandLine.cs diff --git a/src/Elastic.Documentation.Configuration/DocumentationEndpoints.cs b/src/Elastic.Documentation.Configuration/DocumentationEndpoints.cs index dec6e05b98..b93770d15c 100644 --- a/src/Elastic.Documentation.Configuration/DocumentationEndpoints.cs +++ b/src/Elastic.Documentation.Configuration/DocumentationEndpoints.cs @@ -58,7 +58,7 @@ public class ElasticsearchEndpoint public bool DisableSslVerification { get; set; } public X509Certificate? Certificate { get; set; } public bool CertificateIsNotRoot { get; set; } - public int? BootstrapTimeout { get; set; } + public TimeSpan? BootstrapTimeout { get; set; } public bool ForceReindex { get; set; } /// diff --git a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs index d2761cce7f..4ac4ca13b3 100644 --- a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs @@ -142,14 +142,19 @@ public static async Task ApplyAsync( if (options.CertificatePath is not null) { if (!fileSystem.File.Exists(options.CertificatePath.FullName)) + { collector.EmitGlobalError($"'{options.CertificatePath.FullName}' does not exist"); - var bytes = await fileSystem.File.ReadAllBytesAsync(options.CertificatePath.FullName, ctx); - cfg.Certificate = X509CertificateLoader.LoadCertificate(bytes); + } + else + { + var bytes = await fileSystem.File.ReadAllBytesAsync(options.CertificatePath.FullName, ctx); + cfg.Certificate = X509CertificateLoader.LoadCertificate(bytes); + } } if (options.CertificateNotRoot.HasValue) cfg.CertificateIsNotRoot = options.CertificateNotRoot.Value; if (options.BootstrapTimeout.HasValue) - cfg.BootstrapTimeout = (int)options.BootstrapTimeout.Value.TotalMinutes; + cfg.BootstrapTimeout = options.BootstrapTimeout.Value; if (options.AiEnrichment == false) cfg.EnableAiEnrichment = false; if (options.ForceReindex.HasValue) diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index ebce76ee14..5dd63543d5 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -20,22 +20,15 @@ namespace Elastic.Documentation.ServiceDefaults; public static class AppDefaultsExtensions { public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - => builder.AddDocumentationServiceDefaults([], null); + => builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), null); - /// Backward-compatible overload — are scanned but no longer modified. - public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, ref string[] args, Action? configure = null) - where TBuilder : IHostApplicationBuilder - => builder.AddDocumentationServiceDefaults(args, configure); - - public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, string[] args, Action? configure = null) + public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, GlobalCliOptions cliOptions, Action? configure = null) where TBuilder : IHostApplicationBuilder { - var cliArgs = GlobalCli.ScanArgs(args); - var services = builder.Services; - _ = services.AddElasticDocumentationLogging(cliArgs.LogLevel); + _ = services.AddElasticDocumentationLogging(cliOptions.LogLevel); _ = services - .AddConfigurationFileProvider(cliArgs.SkipPrivateRepositories, cliArgs.ConfigurationSource, (s, p) => + .AddConfigurationFileProvider(cliOptions.SkipPrivateRepositories, cliOptions.ConfigSource, (s, p) => { var versionConfiguration = p.CreateVersionConfiguration(); var products = p.CreateProducts(versionConfiguration); @@ -46,7 +39,7 @@ public static TBuilder AddDocumentationServiceDefaults(this TBuilder b _ = s.AddSingleton(search); configure?.Invoke(s, p); }); - _ = services.AddSingleton(cliArgs); + _ = services.AddSingleton(cliOptions); var endpoints = ElasticsearchEndpointFactory.Create(builder.Configuration); _ = services.AddSingleton(endpoints); diff --git a/src/tooling/docs-builder/GlobalCliOptions.cs b/src/Elastic.Documentation/GlobalCliOptions.cs similarity index 92% rename from src/tooling/docs-builder/GlobalCliOptions.cs rename to src/Elastic.Documentation/GlobalCliOptions.cs index 237c90f5e8..cc0af96f8b 100644 --- a/src/tooling/docs-builder/GlobalCliOptions.cs +++ b/src/Elastic.Documentation/GlobalCliOptions.cs @@ -2,10 +2,9 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Documentation; using Microsoft.Extensions.Logging; -namespace Documentation.Builder; +namespace Elastic.Documentation; /// /// Global CLI options available to every command via argh's first-parameter injection. diff --git a/src/Elastic.Documentation/GlobalCommandLine.cs b/src/Elastic.Documentation/GlobalCommandLine.cs deleted file mode 100644 index e5aa42060e..0000000000 --- a/src/Elastic.Documentation/GlobalCommandLine.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using Microsoft.Extensions.Logging; - -namespace Elastic.Documentation; - -/// -/// Early-parse utilities for startup DI setup in AppDefaultsExtensions before the argh host builds. -/// The authoritative CLI options for docs-builder itself live in GlobalCliOptions (docs-builder project). -/// -public static class GlobalCli -{ - /// - /// Scans for startup flags without modifying the array. - /// Used by AppDefaultsExtensions before argh routing runs. - /// - public static GlobalCliArgs ScanArgs(string[] args) - { - var options = new GlobalCliArgs(); - for (var i = 0; i < args.Length; i++) - { - if (args[i] == "--log-level" && i + 1 < args.Length) - options = options with { LogLevel = ParseLogLevel(args[++i]) }; - else if (args[i] is "--config-source" or "--configuration-source" or "-c" && i + 1 < args.Length) - { - if (ConfigurationSourceExtensions.TryParse(args[i + 1], out var cs, true, true)) - options = options with { ConfigurationSource = cs }; - i++; - } - else if (args[i] == "--skip-private-repositories") - options = options with { SkipPrivateRepositories = true }; - } - return options; - } - - private static LogLevel ParseLogLevel(string? logLevel) => logLevel switch - { - "trace" => LogLevel.Trace, - "debug" => LogLevel.Debug, - "information" => LogLevel.Information, - "info" => LogLevel.Information, - "warning" => LogLevel.Warning, - "error" => LogLevel.Error, - "critical" => LogLevel.Critical, - _ => LogLevel.Information - }; -} - -/// Startup args scanned before the DI host builds. Used by AppDefaultsExtensions. -public record GlobalCliArgs -{ - public LogLevel LogLevel { get; init; } = LogLevel.Information; - public ConfigurationSource? ConfigurationSource { get; init; } - public bool SkipPrivateRepositories { get; init; } -} diff --git a/src/api/Elastic.Documentation.Api.App/Program.cs b/src/api/Elastic.Documentation.Api.App/Program.cs index 0fc2eff02e..166d2da1d4 100644 --- a/src/api/Elastic.Documentation.Api.App/Program.cs +++ b/src/api/Elastic.Documentation.Api.App/Program.cs @@ -14,7 +14,7 @@ try { var builder = WebApplication.CreateSlimBuilder(args); - _ = builder.AddDocumentationServiceDefaults(ref args, (s, p) => + _ = builder.AddDocumentationServiceDefaults(configure: (s, p) => { _ = s.AddSingleton(AssemblyConfiguration.Create(p)); }); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index fdbd018b76..6e8100c5cb 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -22,7 +22,7 @@ try { var builder = WebApplication.CreateSlimBuilder(args); - _ = builder.AddDocumentationServiceDefaults(ref args); + _ = builder.AddDocumentationServiceDefaults(); _ = builder.AddDefaultHealthChecks(); _ = builder.AddDocsApiOpenTelemetry(); diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index a666ff3def..9cfe6be0e3 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -51,6 +51,8 @@ public async Task Match([Argument] string? repository = null, [Argument] st serviceInvoker.AddCommand(service, (repository, branchOrTag), static async (s, collector, state, ctx) => { + // ShouldBuild emits GitHub Actions outputs to drive conditional CI steps; + // exit code is always 0 — the bool result is communicated via those outputs, not the process exit. _ = await s.ShouldBuild(collector, state.repository, state.branchOrTag, ctx); return true; }); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 6e235b2ccf..4a0bc718b4 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -85,8 +85,8 @@ public Task Init( } var configPath = _fileSystem.Path.Join(docsFolder.FullName, "changelog.yml"); - var changelogPath = changelogDir?.FullName ?? Path.GetFullPath("changelog"); - var bundlesPath = bundlesDir?.FullName ?? Path.GetFullPath("releases"); + var changelogPath = changelogDir?.FullName ?? _fileSystem.Path.Join(docsFolder.FullName, "changelog"); + var bundlesPath = bundlesDir?.FullName ?? _fileSystem.Path.Join(docsFolder.FullName, "releases"); var useNonDefaultChangelogDir = changelogDir != null; var useNonDefaultBundlesDir = bundlesDir != null; diff --git a/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs b/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs index 0c2d077a05..c5a93e9991 100644 --- a/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs @@ -15,8 +15,10 @@ internal sealed class CatchExceptionMiddleware(ILogger public async ValueTask InvokeAsync(CommandContext context, CommandMiddlewareDelegate next) { - Console.CancelKeyPress += (_, _) => + Console.CancelKeyPress += (_, args) => { + // Suppress OS termination so the OperationCanceledException path below can run gracefully. + args.Cancel = true; logger.LogInformation("Received CTRL+C cancelling"); _cancelKeyPressed = true; }; diff --git a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs index 1ffe552596..827de02f92 100644 --- a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs @@ -24,11 +24,19 @@ public async ValueTask InvokeAsync(CommandContext context, CommandMiddlewareDele if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) return; - var latestVersionUrl = await GetLatestVersion(context.CancellationToken); - if (latestVersionUrl is null) - _logger.LogWarning("Unable to determine latest version"); - else - CompareWithAssemblyVersion(latestVersionUrl); + try + { + var latestVersionUrl = await GetLatestVersion(context.CancellationToken); + if (latestVersionUrl is null) + _logger.LogWarning("Unable to determine latest version"); + else + CompareWithAssemblyVersion(latestVersionUrl); + } + catch (Exception ex) + { + // Best-effort: a network failure here must never break the command that just succeeded. + _logger.LogDebug(ex, "Update check failed"); + } } private void CompareWithAssemblyVersion(Uri latestVersionUrl) @@ -70,19 +78,15 @@ private void CompareWithAssemblyVersion(Uri latestVersionUrl) return uri; } - try + using var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); + using var response = await httpClient.GetAsync("https://github.com/elastic/docs-builder/releases/latest", ct); + var redirectUrl = response.Headers.Location; + if (redirectUrl is not null && _stateFile.Directory is not null) { - var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); - var response = await httpClient.GetAsync("https://github.com/elastic/docs-builder/releases/latest", ct); - var redirectUrl = response.Headers.Location; - if (redirectUrl is not null && _stateFile.Directory is not null) - { - if (!Fs.Directory.Exists(_stateFile.Directory.FullName)) - _ = Fs.Directory.CreateDirectory(_stateFile.Directory.FullName); - await Fs.File.WriteAllTextAsync(_stateFile.FullName, redirectUrl.ToString(), ct); - } - return redirectUrl; + if (!Fs.Directory.Exists(_stateFile.Directory.FullName)) + _ = Fs.Directory.CreateDirectory(_stateFile.Directory.FullName); + await Fs.File.WriteAllTextAsync(_stateFile.FullName, redirectUrl.ToString(), ct); } - finally { } + return redirectUrl; } } diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index 8c4cdebe8e..cfd5cec3db 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -19,8 +19,9 @@ // before the host (and its startup logs) are ever constructed. await ArghApp.TryArghIntrinsicCommand(args); +_ = GlobalCliOptions.TryParseArgh(args, out var cliOptions); var builder = Host.CreateApplicationBuilder() - .AddDocumentationServiceDefaults(args, (s, p) => + .AddDocumentationServiceDefaults(cliOptions ?? new GlobalCliOptions(), (s, p) => { _ = s.AddSingleton(AssemblyConfiguration.Create(p)); }) From 60c30b489e38d89bfc704ad4576f727bb99289ce Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 4 May 2026 21:32:53 +0200 Subject: [PATCH 21/27] refactor: add configure-only overload to AddDocumentationServiceDefaults Adds a convenience overload that accepts only a configure action, so callers that don't need to supply CLI options no longer need the named argument workaround. Updates integration tests and Api.App accordingly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../AppDefaultsExtensions.cs | 7 +++++-- src/api/Elastic.Documentation.Api.App/Program.cs | 2 +- .../NavigationBuildingTests.cs | 3 +-- .../NavigationRootTests.cs | 3 +-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index 5dd63543d5..2bac3679a9 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -19,8 +19,11 @@ namespace Elastic.Documentation.ServiceDefaults; public static class AppDefaultsExtensions { - public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - => builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), null); + public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder => builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), null); + + public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, Action? configure) + where TBuilder : IHostApplicationBuilder => builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), configure); public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, GlobalCliOptions cliOptions, Action? configure = null) where TBuilder : IHostApplicationBuilder diff --git a/src/api/Elastic.Documentation.Api.App/Program.cs b/src/api/Elastic.Documentation.Api.App/Program.cs index 166d2da1d4..980fe483e0 100644 --- a/src/api/Elastic.Documentation.Api.App/Program.cs +++ b/src/api/Elastic.Documentation.Api.App/Program.cs @@ -14,7 +14,7 @@ try { var builder = WebApplication.CreateSlimBuilder(args); - _ = builder.AddDocumentationServiceDefaults(configure: (s, p) => + _ = builder.AddDocumentationServiceDefaults((s, p) => { _ = s.AddSingleton(AssemblyConfiguration.Create(p)); }); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs index 7f7276a627..cb02a3879f 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs @@ -33,9 +33,8 @@ public async Task AssertRealNavigation() { //Skipping on CI since this relies on checking out private repositories Assert.SkipWhen(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")), "Skipping in CI"); - string[] args = []; var builder = Host.CreateApplicationBuilder() - .AddDocumentationServiceDefaults(ref args, (s, p) => + .AddDocumentationServiceDefaults((s, p) => { _ = s.AddSingleton(AssemblyConfiguration.Create(p)); }) diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs index 9db9a751e8..38aa6e64f9 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs @@ -33,9 +33,8 @@ public async Task AssertRealNavigation() { //Skipping on CI since this relies on checking out private repositories Assert.SkipWhen(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")), "Skipping in CI"); - string[] args = []; var builder = Host.CreateApplicationBuilder() - .AddDocumentationServiceDefaults(ref args, (s, p) => + .AddDocumentationServiceDefaults((s, p) => { _ = s.AddSingleton(AssemblyConfiguration.Create(p)); }) From 47619dc3b4a1fa4192670777afd3e84de5a29aef Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 4 May 2026 21:41:42 +0200 Subject: [PATCH 22/27] refactor: make GlobalCliOptions.LogLevel nullable Allows argh to distinguish between "not provided" and "explicitly set to Information", so the default can be applied at the consumption site. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../AppDefaultsExtensions.cs | 2 +- src/Elastic.Documentation/GlobalCliOptions.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index 2bac3679a9..93295ca7dc 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -29,7 +29,7 @@ public static TBuilder AddDocumentationServiceDefaults(this TBuilder b where TBuilder : IHostApplicationBuilder { var services = builder.Services; - _ = services.AddElasticDocumentationLogging(cliOptions.LogLevel); + _ = services.AddElasticDocumentationLogging(cliOptions.LogLevel.GetValueOrDefault(LogLevel.Information)); _ = services .AddConfigurationFileProvider(cliOptions.SkipPrivateRepositories, cliOptions.ConfigSource, (s, p) => { diff --git a/src/Elastic.Documentation/GlobalCliOptions.cs b/src/Elastic.Documentation/GlobalCliOptions.cs index cc0af96f8b..ccf7d0539d 100644 --- a/src/Elastic.Documentation/GlobalCliOptions.cs +++ b/src/Elastic.Documentation/GlobalCliOptions.cs @@ -12,7 +12,7 @@ namespace Elastic.Documentation; public class GlobalCliOptions { /// -l,--log-level, Minimum log level. Default: information - public LogLevel LogLevel { get; set; } = LogLevel.Information; + public LogLevel? LogLevel { get; set; } /// -c,--config-source, Override the configuration source: local, remote public ConfigurationSource? ConfigSource { get; set; } From 3b47b50345b0769c716d8579bd4ebf837dd44212 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 6 May 2026 09:12:10 +0200 Subject: [PATCH 23/27] chore: upgrade argh to 0.13.1, restore non-nullable LogLevel default 0.13.1 fixes non-nullable [AsParameters] / global-options properties with C# initializers being incorrectly required. LogLevel can now be non-nullable with = LogLevel.Information and argh will honour it when the flag is absent, removing the .GetValueOrDefault workaround in AppDefaultsExtensions. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 +++--- .../AppDefaultsExtensions.cs | 2 +- src/Elastic.Documentation/GlobalCliOptions.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1f97d42273..61ce6e5496 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index 93295ca7dc..2bac3679a9 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -29,7 +29,7 @@ public static TBuilder AddDocumentationServiceDefaults(this TBuilder b where TBuilder : IHostApplicationBuilder { var services = builder.Services; - _ = services.AddElasticDocumentationLogging(cliOptions.LogLevel.GetValueOrDefault(LogLevel.Information)); + _ = services.AddElasticDocumentationLogging(cliOptions.LogLevel); _ = services .AddConfigurationFileProvider(cliOptions.SkipPrivateRepositories, cliOptions.ConfigSource, (s, p) => { diff --git a/src/Elastic.Documentation/GlobalCliOptions.cs b/src/Elastic.Documentation/GlobalCliOptions.cs index ccf7d0539d..cc0af96f8b 100644 --- a/src/Elastic.Documentation/GlobalCliOptions.cs +++ b/src/Elastic.Documentation/GlobalCliOptions.cs @@ -12,7 +12,7 @@ namespace Elastic.Documentation; public class GlobalCliOptions { /// -l,--log-level, Minimum log level. Default: information - public LogLevel? LogLevel { get; set; } + public LogLevel LogLevel { get; set; } = LogLevel.Information; /// -c,--config-source, Override the configuration source: local, remote public ConfigurationSource? ConfigSource { get; set; } From 7a4ad55ac93468253c93864a6837f6691e657b2e Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 6 May 2026 11:34:18 +0200 Subject: [PATCH 24/27] chore: upgrade argh to 0.14.0, enable XML doc gen for Elastic.Documentation 0.14.0 fixes two CI failures: - unknown short option '-p': argh now correctly reads short aliases from referenced-assembly XML doc files - missing required flag --log-level: UseGlobalOptions properties with C# initializers are no longer treated as required (same fix as 0.13.1's [AsParameters] fix, now applied to global options too) GenerateDocumentationFile is added to Elastic.Documentation so argh can read the -l and -c short aliases from GlobalCliOptions at generate time. The same NoWarn pattern (CS1591 etc.) already used in three other argh DTO projects is applied here. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 +++--- src/Elastic.Documentation/Elastic.Documentation.csproj | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 61ce6e5496..a638b32dc0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + diff --git a/src/Elastic.Documentation/Elastic.Documentation.csproj b/src/Elastic.Documentation/Elastic.Documentation.csproj index 89463de94f..9720963606 100644 --- a/src/Elastic.Documentation/Elastic.Documentation.csproj +++ b/src/Elastic.Documentation/Elastic.Documentation.csproj @@ -6,6 +6,8 @@ enable Elastic.Documentation true + true + $(NoWarn);CS1591;CS1573;CS1572;CS1571;CS1570;CS1574;CS0419 From bfb386c1634998018d3f17f1f9caa7916967dd69 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 6 May 2026 16:43:05 +0200 Subject: [PATCH 25/27] chore: upgrade argh to 0.15.0 Fixes the CI failures: 'missing required flag --log-level' and 'unknown short option -p' no longer appear when global options are omitted. Skipped 0.14.1 which introduced 102 CS8600 compile errors. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a638b32dc0..a14244c257 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + From 9c775e95ea44c7d162d639b11c9cfcca55c13e47 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 6 May 2026 19:37:22 +0200 Subject: [PATCH 26/27] chore: upgrade argh to 0.15.1, restore --skip-private-repositories in integration tests 0.15.1 relaxes TryParseArgh so unknown flags no longer cause the DTO pre-parser to fail. Previously, passing command-specific flags like --assume-cloned alongside global flags caused TryParseArgh to return false, falling back to new GlobalCliOptions() with SkipPrivateRepositories=false, which caused the assembler to attempt cloning private repositories. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a14244c257..176982ebfc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,9 +69,9 @@ - - - + + + From 62fecfa7d22019df7dbadf32e7c2cd086ce26899 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 6 May 2026 19:59:18 +0200 Subject: [PATCH 27/27] fix: honor both --eis/--no-eis and skip update check on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ElasticsearchEndpointConfigurator now applies both directions of the Eis and AiEnrichment flags so a CLI --eis overrides any config-file value that set NoElasticInferenceService=true, and vice versa. CheckForUpdatesMiddleware skips the version lookup when the command was canceled or exited non-zero — no point advertising an update to a user whose command failed. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ElasticsearchEndpointConfigurator.cs | 8 ++++---- .../docs-builder/Middleware/CheckForUpdatesMiddleware.cs | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs index 4ac4ca13b3..f964a4c2b1 100644 --- a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs @@ -121,8 +121,8 @@ public static async Task ApplyAsync( cfg.SearchNumThreads = options.SearchNumThreads.Value; if (options.IndexNumThreads.HasValue) cfg.IndexNumThreads = options.IndexNumThreads.Value; - if (options.Eis == false) - cfg.NoElasticInferenceService = true; + if (options.Eis.HasValue) + cfg.NoElasticInferenceService = !options.Eis.Value; if (options.BufferSize.HasValue) cfg.BufferSize = options.BufferSize.Value; if (options.MaxRetries.HasValue) @@ -155,8 +155,8 @@ public static async Task ApplyAsync( cfg.CertificateIsNotRoot = options.CertificateNotRoot.Value; if (options.BootstrapTimeout.HasValue) cfg.BootstrapTimeout = options.BootstrapTimeout.Value; - if (options.AiEnrichment == false) - cfg.EnableAiEnrichment = false; + if (options.AiEnrichment.HasValue) + cfg.EnableAiEnrichment = options.AiEnrichment.Value; if (options.ForceReindex.HasValue) cfg.ForceReindex = options.ForceReindex.Value; } diff --git a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs index 827de02f92..34f41953ff 100644 --- a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs @@ -21,6 +21,8 @@ internal sealed class CheckForUpdatesMiddleware(ILogger