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
34 changes: 34 additions & 0 deletions playground/TypeScriptAppHost/apphost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,40 @@ await cache.withProcessCommand(
iconName: "WindowConsole"
}
});
await cache.withProcessCommandFactory(
"node-process-check-factory",
"Node process check with arguments",
async (context: ExecuteCommandContext) => {
const args = await context.arguments();
const message = await args.requiredValue("message");

return {
executablePath: "node",
arguments: [
processCommandScriptPath,
message
],
environmentVariables: {
TS_PROCESS_COMMAND_SAMPLE: "from-process-command-factory"
},
standardInputContent: "hello from TypeScript AppHost factory"
};
},
{
commandOptions: {
description: "Runs a Node process command with arguments from the TypeScript AppHost.",
iconName: "WindowConsole",
arguments: [
{
name: "message",
label: "Message",
inputType: InputType.Text,
required: true
}
]
},
maxOutputLineCount: 10
});

console.log("Added Redis cache");

Expand Down
69 changes: 69 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/ProcessCommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,72 @@ internal sealed class ProcessCommandExportOptions
/// </summary>
public IReadOnlyList<int>? SuccessExitCodes { get; set; }
}

/// <summary>
/// ATS-friendly process specification for resource process command callbacks.
/// </summary>
[AspireDto]
internal sealed class ProcessCommandSpecExportData
{
/// <summary>
/// The executable path or command name to start.
/// </summary>
public string? ExecutablePath { get; set; }

/// <summary>
/// The command-line arguments for the process.
/// </summary>
public IReadOnlyList<string>? Arguments { get; set; }

/// <summary>
/// The working directory for the process.
/// </summary>
public string? WorkingDirectory { get; set; }

/// <summary>
/// The environment variables to set for the process.
/// </summary>
public IReadOnlyDictionary<string, string>? EnvironmentVariables { get; set; }

/// <summary>
/// A value indicating whether the process should inherit the current environment variables.
/// </summary>
public bool? InheritEnvironmentVariables { get; set; }

/// <summary>
/// Standard input content to write to the process after it starts.
/// </summary>
public string? StandardInputContent { get; set; }

/// <summary>
/// A value indicating whether the entire process tree should be killed when the process is disposed.
/// </summary>
public bool? KillEntireProcessTree { get; set; }
}

/// <summary>
/// ATS-friendly result and command configuration for resource process commands.
/// </summary>
[AspireDto]
internal sealed class ProcessCommandResultExportOptions
{
/// <summary>
/// Optional command configuration.
/// </summary>
public CommandOptions? CommandOptions { get; set; }

/// <summary>
/// The maximum number of stdout and stderr output lines returned as command result data.
/// </summary>
public int? MaxOutputLineCount { get; set; }

/// <summary>
/// A value indicating whether returned command output should be displayed immediately in the dashboard.
/// </summary>
public bool? DisplayImmediately { get; set; }

/// <summary>
/// The exit codes that are treated as a successful command invocation.
/// </summary>
public IReadOnlyList<int>? SuccessExitCodes { get; set; }
}
71 changes: 64 additions & 7 deletions src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2872,6 +2872,34 @@ internal static IResourceBuilder<TResource> WithProcessCommandExport<TResource>(
CreateProcessCommandOptions(options));
}

/// <summary>
/// Adds a command to the resource that starts a local process created by a callback when invoked.
/// </summary>
[Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport("withProcessCommandFactory", Description = "Adds a process resource command via callback")]
internal static IResourceBuilder<TResource> WithProcessCommandFactoryExport<TResource>(
this IResourceBuilder<TResource> builder,
string commandName,
string displayName,
Func<ExecuteCommandContext, Task<ProcessCommandSpecExportData>> createProcessSpec,
ProcessCommandResultExportOptions? options = null)
where TResource : IResource
{
ArgumentNullException.ThrowIfNull(createProcessSpec);

return builder.WithProcessCommand(
commandName,
displayName,
async context =>
{
var processCommandSpec = await createProcessSpec(context).ConfigureAwait(false)
?? throw new InvalidOperationException("The process command specification factory returned null.");

return CreateProcessCommandSpec(processCommandSpec);
},
CreateProcessCommandOptions(options));
}

internal static async Task<ExecuteCommandResult> ExecuteProcessCommandAsync(ExecuteCommandContext context, ProcessCommandSpec processCommandSpec, ProcessCommandOptions commandOptions)
{
var processSpec = CreateProcessSpec(context, processCommandSpec, commandOptions);
Expand All @@ -2893,8 +2921,23 @@ internal static async Task<ExecuteCommandResult> ExecuteProcessCommandAsync(Exec
}

private static ProcessCommandOptions CreateProcessCommandOptions(ProcessCommandExportOptions exportOptions)
{
return CreateProcessCommandOptions(new ProcessCommandResultExportOptions
{
CommandOptions = exportOptions.CommandOptions,
MaxOutputLineCount = exportOptions.MaxOutputLineCount,
DisplayImmediately = exportOptions.DisplayImmediately,
SuccessExitCodes = exportOptions.SuccessExitCodes
});
Comment on lines +2925 to +2931

@JamesNK James Newton-King (JamesNK) May 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry that new properties will be added to one option and not the other. Maybe AI will catch it, maybe it won't.

What can we do to validate these are kept in sync? Unit test with reflection that checks properties are the same?

This question is universal for these export types.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we need to figure out a nicer way to manage these. Like having a single API 😄

}

private static ProcessCommandOptions CreateProcessCommandOptions(ProcessCommandResultExportOptions? exportOptions)
{
var commandOptions = new ProcessCommandOptions();
if (exportOptions is null)
{
return commandOptions;
}

if (exportOptions.CommandOptions is { } commonOptions)
{
Expand Down Expand Up @@ -2941,13 +2984,27 @@ private static ProcessCommandOptions CreateProcessCommandOptions(ProcessCommandE

private static ProcessCommandSpec CreateProcessCommandSpec(ProcessCommandExportOptions exportOptions)
{
var executablePath = exportOptions.ExecutablePath;
return CreateProcessCommandSpec(new ProcessCommandSpecExportData
{
ExecutablePath = exportOptions.ExecutablePath,
Arguments = exportOptions.Arguments,
WorkingDirectory = exportOptions.WorkingDirectory,
EnvironmentVariables = exportOptions.EnvironmentVariables,
InheritEnvironmentVariables = exportOptions.InheritEnvironmentVariables,
StandardInputContent = exportOptions.StandardInputContent,
KillEntireProcessTree = exportOptions.KillEntireProcessTree
});
}

private static ProcessCommandSpec CreateProcessCommandSpec(ProcessCommandSpecExportData exportData)
{
var executablePath = exportData.ExecutablePath;
if (string.IsNullOrWhiteSpace(executablePath))
{
throw new DistributedApplicationException("Process command requires a non-empty executable path.");
}

var arguments = exportOptions.Arguments ?? [];
var arguments = exportData.Arguments ?? [];
foreach (var argument in arguments)
{
if (argument is null)
Expand All @@ -2958,12 +3015,12 @@ private static ProcessCommandSpec CreateProcessCommandSpec(ProcessCommandExportO

return new ProcessCommandSpec(executablePath)
{
WorkingDirectory = exportOptions.WorkingDirectory,
WorkingDirectory = exportData.WorkingDirectory,
Arguments = arguments.ToArray(),
EnvironmentVariables = CreateEnvironmentVariables(exportOptions.EnvironmentVariables),
InheritEnvironmentVariables = exportOptions.InheritEnvironmentVariables ?? true,
StandardInputContent = exportOptions.StandardInputContent,
KillEntireProcessTree = exportOptions.KillEntireProcessTree ?? true
EnvironmentVariables = CreateEnvironmentVariables(exportData.EnvironmentVariables),
InheritEnvironmentVariables = exportData.InheritEnvironmentVariables ?? true,
StandardInputContent = exportData.StandardInputContent,
KillEntireProcessTree = exportData.KillEntireProcessTree ?? true
};
}

Expand Down
Loading
Loading