diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8cd41237df8..6debbdfaa79 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -52,9 +52,9 @@ - - - + + + diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Apis/CreateApiCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Apis/CreateApiCommand.cs index 3db6d225766..221c9581961 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Apis/CreateApiCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Apis/CreateApiCommand.cs @@ -54,6 +54,11 @@ private static async Task ExecuteAsync( Opt.Instance, ct); + if (!pathResult.StartsWith('/')) + { + throw new ExitException($"The path '{pathResult.EscapeMarkup()}' is invalid. It must start with '/'."); + } + var path = pathResult.Split("/", TrimEntries | RemoveEmptyEntries); var kind = GetApiKind(parseResult); diff --git a/src/Nitro/CommandLine/src/CommandLine/Helpers/SelectableTable.cs b/src/Nitro/CommandLine/src/CommandLine/Helpers/SelectableTable.cs index 09779cbbc88..14481f479ea 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Helpers/SelectableTable.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Helpers/SelectableTable.cs @@ -174,9 +174,7 @@ private Table CreateTable() ctx.UpdateTarget(new Rows( title, - CreateTable() - .Centered() - .AddRows(SelectedIndex, Items.Select(CreateRow)), + Align.Center(CreateTable().AddRows(SelectedIndex, Items.Select(CreateRow))), Align.Center(new Rows(columns)))); ctx.Refresh(); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Console/ActivityTree.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Console/ActivityTree.cs index c56eb04a2a2..2e250a06eb2 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Console/ActivityTree.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Console/ActivityTree.cs @@ -17,6 +17,8 @@ internal sealed class ActivityTree : Renderable private readonly List _rootEntries = []; private readonly Spinner _spinner = Spinner.Known.Default; + public bool EmitLivePadding { get; set; } = true; + public ActivityEntry AddRoot(string text) { lock (_lock) @@ -92,13 +94,15 @@ protected override IEnumerable Render(RenderOptions options, int maxWid RenderEntry(segments, root, parentPrefix: "", NodePosition.Root, options, maxWidth); } - // Workaround for a Spectre.Console bug: LiveRenderable.PositionCursor emits - // `CSI 0 A` when the rendered shape is one line tall, and most terminals treat - // that as `CSI 1 A` (move cursor up one row) per ECMA-48 default-parameter - // handling. The result is that a single-line tree drifts up one row on every - // refresh and overwrites previously-printed output. Forcing the shape to be at - // least two lines tall keeps Spectre on the `CursorUp(n>=1)` path. - segments.Add(Segment.LineBreak); + // Workaround for a Spectre.Console bug (issue #2076): LiveRenderable.PositionCursor + // emits `CSI 0 A` when the rendered shape is one line tall, and most terminals treat + // that as `CSI 1 A` (move cursor up one row) per ECMA-48 default-parameter handling. + // Forcing the shape to be at least two lines tall keeps Spectre on the + // `CursorUp(n>=1)` path. Only needed while the tree is rendered through Live. + if (EmitLivePadding) + { + segments.Add(Segment.LineBreak); + } return segments; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Console/LiveActivitySink.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Console/LiveActivitySink.cs index 02b8712e85e..48e6dfc6af8 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Console/LiveActivitySink.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Console/LiveActivitySink.cs @@ -61,7 +61,7 @@ private async Task RunAsync() { await _console .Live(_tree) - .AutoClear(false) + .AutoClear(true) .Overflow(VerticalOverflow.Visible) .StartAsync(async ctx => { @@ -73,10 +73,11 @@ await _console ctx.Refresh(); }); - // ActivityTree pads its output with a trailing blank line to work around a - // Spectre.Console bug (see ActivityTree.Render). After Live completes, Spectre - // has left the cursor one row below that padding. Step back onto the padded - // row so subsequent output overwrites it instead of leaving a visible gap. - _console.Cursor.Move(CursorDirection.Up, 1); + // Live cleared its rendered region. Re-emit the final tree state as a plain + // renderable so it lands in scrollback as normal output. This avoids relying + // on cursor math to merge live output with subsequent stdout/stderr writes, + // which was the source of rendering bleed on Windows terminals. + _tree.EmitLivePadding = false; + _console.Write(_tree); } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs index 1df8d052dbb..056505a8626 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs @@ -64,6 +64,20 @@ public void Write(IRenderable renderable) + "Check the documentation of the command to see all options"); } + public void WriteAnsi(Action action) + { + if (IsHumanReadable) + { + _hasWrittenOutput = true; + outConsole.WriteAnsi(action); + return; + } + + throw new ExitException( + "Console runs in non interactive mode, yet a user interaction was attempted. " + + "Check the documentation of the command to see all options"); + } + public Profile Profile => outConsole.Profile; public IAnsiConsoleCursor Cursor => outConsole.Cursor; diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Apis/CreateApiCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Apis/CreateApiCommandTests.cs index e09724f8988..5eb4c0834f7 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Apis/CreateApiCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Apis/CreateApiCommandTests.cs @@ -476,6 +476,28 @@ Creating API 'my-api' """); } + [Theory] + [InlineData("products")] + [InlineData("C:/Program Files/Git/test")] + public async Task Create_Should_ReturnError_When_PathDoesNotStartWithSlash(string path) + { + // arrange + SetupSessionWithWorkspace(); + + // act + var result = await ExecuteCommandAsync( + "api", + "create", + "--name", + ApiName, + "--path", + path); + + // assert + result.AssertError( + $"The path '{path}' is invalid. It must start with '/'."); + } + [Fact] public async Task Create_Should_ReturnSuccess_When_KindNotProvided() {