Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public sealed class EndpointMetadataTests : IDisposable
"GET:/internal-api/live",
"GET:/internal-api/ready",
// /internal-api/account/tenants/{id} stays anonymous: server-to-server call from main SCS in
// downstream projects (DataMentor, ProductConnect). Until downstream callers can pass an auth
// token, the BlockInternalApiTransform + ACA private ingress are the perimeter.
// downstream projects. Until downstream callers can pass an auth token, the
// BlockInternalApiTransform + ACA private ingress are the perimeter.
"DELETE:/internal-api/account/tenants/{id}",
// SinglePageAppFallbackExtensions registers a framework-level catch-all that emits 404 for
// any unmatched /internal-api/* path. The 404 emitter is not a callable endpoint.
Expand Down
222 changes: 222 additions & 0 deletions developer-cli/Commands/BannedPushWordsCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
using System.CommandLine;
using System.Text.RegularExpressions;
using DeveloperCli.Installation;
using DeveloperCli.Utilities;
using Spectre.Console;

namespace DeveloperCli.Commands;

public sealed class BannedPushWordsCommand : Command
{
public BannedPushWordsCommand() : base("banned-push-words", "Configure words banned from diffs and which remotes to enforce them on")
{
var wordsArgument = new Argument<string[]>("words")
{
Description = "Words to ban (replaces the current list)",
Arity = ArgumentArity.ZeroOrMore
};
var clearOption = new Option<bool>("--clear") { Description = "Remove all banned words and enforced remotes" };

Arguments.Add(wordsArgument);
Options.Add(clearOption);

SetAction(parseResult => Execute(
parseResult.GetValue(wordsArgument) ?? [],
parseResult.GetValue(clearOption)
)
);
}

private static void Execute(string[] words, bool clear)
{
var wordsFilePath = Path.Combine(Configuration.PublishFolder, $"{Configuration.AliasName}.banned-push-words");
var remotesFilePath = Path.Combine(Configuration.PublishFolder, $"{Configuration.AliasName}.banned-push-words-remotes");

if (clear)
{
if (File.Exists(wordsFilePath)) File.Delete(wordsFilePath);
if (File.Exists(remotesFilePath)) File.Delete(remotesFilePath);
AnsiConsole.MarkupLine("[green]Cleared all banned push words and enforced remotes.[/]");
return;
}

if (words.Length == 0)
{
ListCurrentConfiguration(wordsFilePath, remotesFilePath);
return;
}

Directory.CreateDirectory(Configuration.PublishFolder);

SetBannedWords(wordsFilePath, words);
PromptEnforcedRemotes(remotesFilePath);
}

private static void ListCurrentConfiguration(string wordsFilePath, string remotesFilePath)
{
var hasWords = File.Exists(wordsFilePath) && new FileInfo(wordsFilePath).Length > 0;
var hasRemotes = File.Exists(remotesFilePath) && new FileInfo(remotesFilePath).Length > 0;

if (!hasWords && !hasRemotes)
{
AnsiConsole.MarkupLine("[yellow]No push guards configured.[/]");
AnsiConsole.MarkupLine($"[grey]Set up with: [blue]{Configuration.AliasName} banned-push-words Word1 Word2[/][/]");
return;
}

if (hasWords)
{
var existingWords = File.ReadAllLines(wordsFilePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
AnsiConsole.MarkupLine("[blue]Banned push words:[/]");
PrintWordsWithPermutations(existingWords);
AnsiConsole.WriteLine();
}

if (hasRemotes)
{
var enforcedUrls = File.ReadAllLines(remotesFilePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
var remotes = GetPushRemotes();
AnsiConsole.MarkupLine("[blue]Enforced on remotes:[/]");
foreach (var url in enforcedUrls)
{
var name = remotes.FirstOrDefault(r => NormalizeGitUrl(r.Url).Equals(url, StringComparison.OrdinalIgnoreCase))?.Name;
AnsiConsole.MarkupLine(name is not null ? $" - [blue]{Markup.Escape(name)}[/] → {Markup.Escape(url)}" : $" - {Markup.Escape(url)}");
}
}
else if (hasWords)
{
AnsiConsole.MarkupLine("[grey]Enforced on: all remotes (no specific remotes selected)[/]");
}
}

private static void SetBannedWords(string filePath, string[] words)
{
File.WriteAllLines(filePath, words);

AnsiConsole.MarkupLine($"[green]Set {words.Length} banned push word(s):[/]");
PrintWordsWithPermutations(words);
AnsiConsole.WriteLine();
}

private static void PromptEnforcedRemotes(string remotesFilePath)
{
var remotes = GetPushRemotes();
if (remotes.Length == 0) return;

if (!AnsiConsole.Profile.Capabilities.Interactive) return;

var enforcedUrls = File.Exists(remotesFilePath)
? File.ReadAllLines(remotesFilePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToHashSet(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);

if (enforcedUrls.Count > 0)
{
AnsiConsole.MarkupLine("[blue]Currently enforced on:[/]");
foreach (var url in enforcedUrls)
{
var name = remotes.FirstOrDefault(r => NormalizeGitUrl(r.Url).Equals(url, StringComparison.OrdinalIgnoreCase))?.Name;
AnsiConsole.MarkupLine(name is not null ? $" - [blue]{Markup.Escape(name)}[/] → {Markup.Escape(url)}" : $" - {Markup.Escape(url)}");
}

AnsiConsole.WriteLine();
}

var selected = AnsiConsole.Prompt(
new MultiSelectionPrompt<PushRemote>()
.Title("Select remotes to [blue]enforce[/] banned words on:")
.NotRequired()
.InstructionsText("[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to confirm. None = enforce on all.)[/]")
.UseConverter(r => $"{Markup.Escape(r.Name)} → {Markup.Escape(r.Url)}")
.AddChoices(remotes)
);

var normalizedUrls = selected.Select(r => NormalizeGitUrl(r.Url)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();

if (normalizedUrls.Length > 0)
{
File.WriteAllLines(remotesFilePath, normalizedUrls);
AnsiConsole.MarkupLine($"[green]Enforcing banned words on {normalizedUrls.Length} remote(s):[/]");
foreach (var remote in selected)
{
AnsiConsole.MarkupLine($" - [blue]{Markup.Escape(remote.Name)}[/] → {Markup.Escape(remote.Url)}");
}
}
else
{
if (File.Exists(remotesFilePath)) File.Delete(remotesFilePath);
AnsiConsole.MarkupLine("[green]Enforcing banned words on all remotes.[/]");
}
}

private static PushRemote[] GetPushRemotes()
{
var output = ProcessHelper.StartProcess("git remote -v", Configuration.SourceCodeFolder, true, exitOnError: false).Trim();
if (string.IsNullOrEmpty(output)) return [];

return output.Split('\n')
.Where(line => line.Contains("(push)"))
.Select(line =>
{
var parts = line.Split('\t', 2);
var name = parts[0];
var url = parts[1].Replace("(push)", "").Trim();
return new PushRemote(name, url);
}
)
.ToArray();
}

private static string NormalizeGitUrl(string url)
{
url = url.Trim();
url = Regex.Replace(url, "^https?://", "");
url = Regex.Replace(url, "^git@([^:]+):", "$1/");
url = Regex.Replace(url, @"\.git$", "");
return url.TrimEnd('/').ToLowerInvariant();
}

private static void PrintWordsWithPermutations(string[] words)
{
foreach (var word in words)
{
var permutations = GeneratePermutations(word);
AnsiConsole.MarkupLine($" - [blue]{Markup.Escape(word)}[/]");
if (permutations.Length > 0)
{
AnsiConsole.MarkupLine($" [grey]also matches: {Markup.Escape(string.Join(", ", permutations))}[/]");
}
}
}

private static string[] GeneratePermutations(string word)
{
var parts = SplitPascalCase(word);

if (parts.Length <= 1)
{
return [word.ToLowerInvariant(), word.ToUpperInvariant()];
}

var lowerParts = parts.Select(p => p.ToLowerInvariant()).ToArray();
var upperParts = parts.Select(p => p.ToUpperInvariant()).ToArray();
var camel = lowerParts[0] + string.Join("", parts.Skip(1));

return
[
camel,
string.Join("_", lowerParts),
string.Join("-", lowerParts),
string.Join("_", upperParts),
string.Join(" ", parts),
string.Concat(lowerParts)
];
}

private static string[] SplitPascalCase(string word)
{
var parts = Regex.Split(word, "(?<=[a-z])(?=[A-Z])");
return parts.Where(p => p.Length > 0).ToArray();
}

private sealed record PushRemote(string Name, string Url);
}
2 changes: 1 addition & 1 deletion developer-cli/Commands/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ private static void Execute(bool backend, bool frontend, bool emails, bool devel

if (quiet)
{
Console.WriteLine("Build succeeded.");
Console.WriteLine($"Build succeeded in {Stopwatch.GetElapsedTime(startTime).Format()}.");
}
else
{
Expand Down
9 changes: 8 additions & 1 deletion developer-cli/Commands/End2EndCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,14 @@ private static void Execute(

stopwatch.Stop();

if (!quiet)
if (quiet)
{
Console.WriteLine(overallSuccess
? $"All tests completed in {stopwatch.Elapsed.TotalSeconds:F1}s."
: $"Some tests failed in {stopwatch.Elapsed.TotalSeconds:F1}s."
);
}
else
{
AnsiConsole.MarkupLine(overallSuccess
? $"[green]All tests completed in {stopwatch.Elapsed.TotalSeconds:F1} seconds[/]"
Expand Down
14 changes: 13 additions & 1 deletion developer-cli/Commands/FormatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri

try
{
const string cacheKey = "format";
if (SourceStateCache.IsUpToDate(cacheKey))
{
if (quiet)
Console.WriteLine("No changes since last format run, skipping.");
else
AnsiConsole.MarkupLine("[green]No changes since last format run, skipping.[/]");
return;
}

var initialUncommittedFiles = quiet ? null : GitHelper.GetChangedFiles();
if (!quiet && initialUncommittedFiles!.Count > 0)
{
Expand Down Expand Up @@ -84,9 +94,11 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri
developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime;
}

SourceStateCache.Save(cacheKey);

if (quiet)
{
Console.WriteLine("Code formatted successfully.");
Console.WriteLine($"Code formatted successfully in {Stopwatch.GetElapsedTime(startTime).Format()}.");
}
else
{
Expand Down
14 changes: 13 additions & 1 deletion developer-cli/Commands/LintCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri

try
{
const string cacheKey = "lint";
if (SourceStateCache.IsUpToDate(cacheKey))
{
if (quiet)
Console.WriteLine("No changes since last lint run, skipping.");
else
AnsiConsole.MarkupLine("[green]No changes since last lint run, skipping.[/]");
return;
}

var startTime = Stopwatch.GetTimestamp();
var backendTime = TimeSpan.Zero;
var frontendTime = TimeSpan.Zero;
Expand Down Expand Up @@ -82,6 +92,8 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri
developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime;
}

if (!hasIssues) SourceStateCache.Save(cacheKey);

if (quiet)
{
if (hasIssues)
Expand All @@ -90,7 +102,7 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri
Environment.Exit(1);
}

Console.WriteLine("Linting completed successfully. No issues found.");
Console.WriteLine($"Linting completed successfully in {Stopwatch.GetElapsedTime(startTime).Format()}. No issues found.");
}
else
{
Expand Down
9 changes: 9 additions & 0 deletions developer-cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ private static void TailLogUntilReady(string logPath)
{
const string readyMarker = "Distributed application started.";
const string misleadingShutdownHint = " Press Ctrl+C to shut down.";
const string dashboardLoginMarker = "Login to the dashboard at ";
var deadline = DateTime.UtcNow.AddSeconds(60);
var offset = 0L;
var sawFirstLine = false;
Expand All @@ -332,6 +333,14 @@ private static void TailLogUntilReady(string logPath)
var displayLine = line.Replace(misleadingShutdownHint, "").TrimEnd();
AnsiConsole.WriteLine(displayLine);

var dashboardIndex = line.IndexOf(dashboardLoginMarker, StringComparison.Ordinal);
if (dashboardIndex >= 0)
{
var dashboardUrl = line[(dashboardIndex + dashboardLoginMarker.Length)..].Trim();
var dashboardUrlPath = Path.Combine(Configuration.WorkspaceFolder, "aspire-dashboard-url.txt");
File.WriteAllText(dashboardUrlPath, dashboardUrl);
}

if (line.Contains(readyMarker))
{
AnsiConsole.MarkupLine("[green]Aspire AppHost is ready.[/]");
Expand Down
Loading
Loading