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
35 changes: 27 additions & 8 deletions src/Abstractions/Quickstart/IQuickstartProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ namespace Azure.Functions.Cli.Quickstart;
/// language selection and post-scaffold guidance.
/// </summary>
/// <remarks>
/// When <see cref="SupportedLanguages"/> contains more than one entry, the
/// command prompts the user to pick a sub-language (e.g. .NET → C#/F#,
/// Node → JavaScript/TypeScript).
/// Available languages are derived from the manifest at runtime. The provider
/// declares which manifest language values it handles via
/// <see cref="ManifestLanguages"/> and owns the bidirectional mapping between
/// user-facing names (matching <c>func init</c>) and manifest values.
/// </remarks>
public interface IQuickstartProvider
{
Expand All @@ -28,14 +29,32 @@ public interface IQuickstartProvider
public string DisplayName { get; }

/// <summary>
/// Manifest-level language values this stack handles (e.g. ["CSharp", "FSharp"]).
/// When the list has more than one entry, the command prompts for a sub-language.
/// Manifest <see cref="QuickstartEntry.Language"/> values this provider
/// handles, in preferred display order (e.g. ["CSharp"] for dotnet,
/// ["TypeScript", "JavaScript"] for node). The command intersects these
/// with the manifest to determine which languages are actually available,
/// preserving the order declared here.
/// </summary>
public IReadOnlyList<string> SupportedLanguages { get; }
public IReadOnlyList<string> ManifestLanguages { get; }

/// <summary>
/// Returns post-scaffold "next steps" for the given language
/// Maps a manifest language value to a user-friendly display name
/// (e.g. "CSharp" → "C#"). Returns <paramref name="manifestLanguage"/>
/// unchanged when no special mapping is needed.
/// </summary>
public string GetDisplayLanguage(string manifestLanguage);

/// <summary>
/// Resolves user input (canonical name or alias, e.g. "c#", "csharp", "js")
/// to the corresponding manifest language value (e.g. "CSharp", "JavaScript").
/// Returns <c>null</c> when the input is not recognized by this provider.
/// Accepted values match <c>func init --language</c> for UX consistency.
/// </summary>
public string? ResolveManifestLanguage(string userInput);

/// <summary>
/// Returns post-scaffold "next steps" for the given manifest language
/// (e.g. "npm install", "pip install -r requirements.txt", "func start").
/// </summary>
public IReadOnlyList<string> GetNextSteps(string language);
public IReadOnlyList<string> GetNextSteps(string manifestLanguage);
}
5 changes: 5 additions & 0 deletions src/Abstractions/Quickstart/QuickstartConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ public static class QuickstartConstants
/// The GitHub hostname used for URL validation and archive downloads.
/// </summary>
public const string GitHubHostName = "github.com";

/// <summary>
/// The git ref prefix required for all template entries.
/// </summary>
public const string TagRefPrefix = "refs/tags/";
}
240 changes: 220 additions & 20 deletions src/Func/Commands/Quickstart/QuickstartCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.CommandLine;
using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.Console;
using Azure.Functions.Cli.Hosting;
using Azure.Functions.Cli.Quickstart;
Expand All @@ -18,7 +19,7 @@ internal sealed class QuickstartCommand : FuncCliCommand, IBuiltInCommand
{
public Option<string?> StackOption { get; } = new("--stack", "-s")
{
Description = "The stack to use. Run `func workload list` to see what's installed."
Description = QuickstartMessages.StackOptionDescription
};

public Option<string?> LanguageOption { get; } = new("--language", "-l")
Expand All @@ -28,7 +29,7 @@ internal sealed class QuickstartCommand : FuncCliCommand, IBuiltInCommand

public Option<string?> TemplateOption { get; } = new("--template", "-t")
{
Description = "Template ID from the manifest (e.g. http-trigger-python-azd) — skips all prompts"
Description = "Template ID from the manifest (e.g. http-trigger-python-azd) — skips template selection prompts"
};

public Option<string?> ResourceOption { get; } = new("--resource", "-r")
Expand All @@ -43,7 +44,7 @@ internal sealed class QuickstartCommand : FuncCliCommand, IBuiltInCommand

public Option<string?> SearchOption { get; } = new("--search")
{
Description = "Case-insensitive substring filter applied to template names and descriptions"
Description = "Case-insensitive substring match against IDs, template names, resource type, Infrastructure as Code type, and descriptions"
};

public Option<FetchMode> FetchOption { get; } = new("--fetch")
Expand All @@ -52,25 +53,40 @@ internal sealed class QuickstartCommand : FuncCliCommand, IBuiltInCommand
DefaultValueFactory = _ => FetchMode.Auto
};

public Option<bool> ForceOption { get; } = new("--force")
{
Description = "Scaffolds even when the target folder isn't empty. Clears the folder (except .git) before scaffolding. In non-interactive mode, proceeds without confirmation."
};

private readonly IInteractionService _interaction;
private readonly IReadOnlyList<IQuickstartProvider> _providers;
private readonly IQuickstartProviderResolver _resolver;
private readonly IQuickstartManifestService _manifestService;
private readonly IQuickstartScaffolder _scaffolder;

public QuickstartCommand(
QuickstartListCommand listCommand,
QuickstartInfoCommand infoCommand,
IInteractionService interaction,
IQuickstartProviderResolver resolver,
IQuickstartManifestService manifestService,
IQuickstartScaffolder scaffolder,
IEnumerable<IQuickstartProvider> providers)
: base("quickstart", "Browse and scaffold complete function apps from the Azure Functions template catalog.")
{
ArgumentNullException.ThrowIfNull(listCommand);
ArgumentNullException.ThrowIfNull(infoCommand);
ArgumentNullException.ThrowIfNull(interaction);
ArgumentNullException.ThrowIfNull(resolver);
ArgumentNullException.ThrowIfNull(manifestService);
ArgumentNullException.ThrowIfNull(scaffolder);
ArgumentNullException.ThrowIfNull(providers);

_interaction = interaction;
_providers = providers.ToList();
_resolver = resolver;
_manifestService = manifestService;
_scaffolder = scaffolder;

LanguageOption.Description = BuildLanguageOptionDescription(_providers);
LanguageOption.Description = BuildLanguageOptionDescription(providers.ToList());

AddPathArgument();
Options.Add(StackOption);
Expand All @@ -80,35 +96,219 @@ public QuickstartCommand(
Options.Add(IacOption);
Options.Add(SearchOption);
Options.Add(FetchOption);
Options.Add(ForceOption);

Subcommands.Add(listCommand);
Subcommands.Add(infoCommand);
}

protected override Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
string? requestedStack = parseResult.GetValue(StackOption);
string? requestedLanguage = parseResult.GetValue(LanguageOption);
string? templateId = parseResult.GetValue(TemplateOption);
string? resource = parseResult.GetValue(ResourceOption);
string? iac = parseResult.GetValue(IacOption);
string? search = parseResult.GetValue(SearchOption);
FetchMode fetchMode = parseResult.GetValue(FetchOption);
bool force = parseResult.GetValue(ForceOption);

// 1. Resolve stack → provider
IQuickstartProvider? provider = await _resolver.SelectProviderAsync(requestedStack, "scaffold a quickstart project", cancellationToken);
if (provider is null)
{
return 1;
}

// 2. Fetch manifest
QuickstartManifest manifest = await _interaction.ShowStatusAsync(
QuickstartMessages.FetchingCatalogStatus,
_manifestService.GetManifestAsync,
cancellationToken);

// 3. Resolve language
(string? manifestLanguage, int? langError) = await _resolver.ResolveOrPromptLanguageAsync(
requestedLanguage, provider, manifest, cancellationToken);
if (langError is int langCode)
{
return langCode;
}

// 4. Select template
QuickstartEntry? entry;
if (!string.IsNullOrWhiteSpace(templateId))
{
entry = FindTemplateById(templateId, manifestLanguage!, provider, manifest);
if (entry is null)
{
return 1;
}
}
else
{
(entry, int? errorCode) = await SelectTemplateAsync(manifest, manifestLanguage!, resource, iac, search, cancellationToken);
if (errorCode is int code)
{
return code;
}
}

// 5. Resolve directory + --force
WorkingDirectory workingDirectory = parseResult.GetValue(PathArgument!)!;
workingDirectory.CreateIfNotExists();

if (!force && DirectoryGuard.HasNonGitContent(workingDirectory.Info))
{
_interaction.WriteError(QuickstartMessages.DirectoryNotEmptyError);
return 1;
}

if (force)
{
if (!await ConfirmClearDirectoryAsync(workingDirectory.Info, cancellationToken))
{
_interaction.WriteHint(QuickstartMessages.CancelledHint);
return 1;
}

DirectoryGuard.ClearExceptGit(workingDirectory.Info);
}

// 6. Scaffold
await _interaction.StatusAsync(
$"Scaffolding '{entry!.DisplayName}'...",
ct => _scaffolder.ScaffoldAsync(entry, workingDirectory.Info.FullName, fetchMode, ct),
cancellationToken);

// 7. Next steps
_interaction.WriteBlankLine();
_interaction.WriteLine(l => l
.Success(QuickstartMessages.SuccessIcon)
.Muted("Scaffolded ")
.Code(entry.DisplayName)
.Muted(" for ")
.Code(provider.DisplayName)
.Muted("."));

IReadOnlyList<string> steps = provider.GetNextSteps(manifestLanguage!);
if (steps.Count > 0)
{
_interaction.WriteBlankLine();
_interaction.WriteHint("Next steps:");

string targetPath = workingDirectory.Info.FullName;
string currentDir = Directory.GetCurrentDirectory();
if (!string.Equals(targetPath, currentDir, StringComparison.OrdinalIgnoreCase))
{
string displayPath = workingDirectory.OriginalPath ?? targetPath;
_interaction.WriteLine(l => l.Muted($"{QuickstartMessages.StepBullet}Run `cd \"{displayPath}\"`"));
}

foreach (string step in steps)
{
_interaction.WriteLine(l => l.Muted($"{QuickstartMessages.StepBullet}{step}"));
}
}

return 0;
}

private QuickstartEntry? FindTemplateById(string templateId, string manifestLanguage, IQuickstartProvider provider, QuickstartManifest manifest)
{
if (_providers.Count == 0)
QuickstartEntry? entry = manifest.Entries.FirstOrDefault(e =>
string.Equals(e.Id, templateId, StringComparison.OrdinalIgnoreCase));

if (entry is null)
{
_interaction.WriteError(
$"Template '{templateId}' not found in the catalog. " + QuickstartMessages.TemplateNotFoundHint);
return null;
}

if (!string.Equals(entry.Language, manifestLanguage, StringComparison.OrdinalIgnoreCase))
{
_interaction.WriteWarning(
"The quickstart command is not yet available. Stack support is coming in a future release.");
return Task.FromResult(1);
_interaction.WriteError(
$"Template '{templateId}' is for {entry.Language}, " +
$"but you selected {provider.GetDisplayLanguage(manifestLanguage)}. " +
$"Pass --language {entry.Language.ToLowerInvariant()} to use this template.");
return null;
}

return entry;
}

private async Task<(QuickstartEntry? Entry, int? ErrorCode)> SelectTemplateAsync(
QuickstartManifest manifest,
string manifestLanguage,
string? resource,
string? iac,
string? search,
CancellationToken cancellationToken)
{
IReadOnlyList<QuickstartEntry> filtered = manifest.Filter(manifestLanguage, resource, iac, search);

if (filtered.Count == 0)
{
_interaction.WriteError(QuickstartMessages.NoMatchingFiltersError);
return (null, 1);
}

if (filtered.Count == 1)
{
return (filtered[0], null);
}

if (!_interaction.IsInteractive)
{
_interaction.WriteError(QuickstartMessages.MultipleMatchesError);
return (null, 1);
}

var displayToEntry = new Dictionary<string, QuickstartEntry>(StringComparer.Ordinal);
foreach (QuickstartEntry entry in filtered)
{
string label = entry.ShortDescription is not null
? $"{entry.DisplayName} — {entry.ShortDescription}"
: entry.DisplayName;

// Handle duplicate display names by appending the ID
if (!displayToEntry.TryAdd(label, entry))
{
label = $"{entry.DisplayName} ({entry.Id})";
displayToEntry[label] = entry;
}
}

string picked = await _interaction.PromptForSelectionAsync(
"Select a template:",
displayToEntry.Keys,
cancellationToken);

return displayToEntry.TryGetValue(picked, out QuickstartEntry? chosen) ? (chosen, null) : (null, 1);
}

private async Task<bool> ConfirmClearDirectoryAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
if (!DirectoryGuard.HasNonGitContent(workingDirectory))
{
return true;
}

// Implementation will be added in a follow-up PR:
// 1. Fetch manifest via IQuickstartManifestService
// 2. Resolve stack/language (prompt if interactive, error if non-TTY without --language)
// 3. Resolve template (--template skips prompts, otherwise filter + prompt)
// 4. Scaffold via IQuickstartScaffolder
_interaction.WriteWarning(
"The quickstart command is not yet implemented. This is a placeholder for the upcoming scaffold flow.");
return Task.FromResult(1);
$"--force will delete all files in '{workingDirectory.FullName}' (except .git) before scaffolding.");

if (!_interaction.IsInteractive)
{
return true;
}

return await _interaction.ConfirmAsync("Continue?", defaultValue: false, cancellationToken);
}

private static string BuildLanguageOptionDescription(IReadOnlyList<IQuickstartProvider> providers)
{
var languages = providers
.SelectMany(p => p.SupportedLanguages)
.Where(l => !string.IsNullOrWhiteSpace(l))
.SelectMany(p => p.ManifestLanguages)
.Select(l => l.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(l => l, StringComparer.Ordinal)
Expand Down
Loading
Loading