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
52 changes: 39 additions & 13 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "winapp",
"version": "0.7.2",
"schemaVersion": "1.0",
"description": "CLI for Windows app development, including package identity, packaging, managing Package.appxmanifest, test certificates, Windows (App) SDK projections, and more. For use with any app framework targeting Windows",
"description": "Create, run, debug, test, and package Windows apps from the command line. Works with WinUI and any other (cross-platform) app framework targeting Windows, and manages Windows SDKs, package identity, manifests, and certificates.",
"hidden": false,
"options": {
"--cli-schema": {
Expand Down Expand Up @@ -36,18 +36,6 @@
"required": false,
"recursive": true
},
"--on": {
"description": "Run this command on the named execution target instead of this machine. Supported: 'sandbox' (the Windows Sandbox winapp manages) and 'local' (the default). There is no fallback: if the target cannot be prepared, the command fails rather than running here.",
"hidden": false,
"valueType": "System.String",
"hasDefaultValue": false,
"arity": {
"minimum": 1,
"maximum": 1
},
"required": false,
"recursive": true
},
"--version": {
"description": "Show version information",
"hidden": false,
Expand Down Expand Up @@ -2832,6 +2820,18 @@
"required": false,
"recursive": false
},
"--on": {
"description": "Run this command on the named execution target instead of this machine. Supported: 'sandbox' (the Windows Sandbox winapp manages) and 'local' (the default). There is no fallback: if the target cannot be prepared, the command fails rather than running here.",
"hidden": false,
"valueType": "System.String",
"hasDefaultValue": false,
"arity": {
"minimum": 1,
"maximum": 1
},
"required": false,
"recursive": true
},
"--output-appx-directory": {
"description": "Output directory for the loose layout package. If not specified, a directory named AppX inside the input directory will be used.",
"hidden": false,
Expand Down Expand Up @@ -3657,6 +3657,20 @@
"ui": {
"description": "Inspect and interact with any running Windows app using UI Automation (UIA). Works with WPF, WinForms, Win32, Electron, and WinUI 3 apps.",
"hidden": false,
"options": {
"--on": {
"description": "Run this command on the named execution target instead of this machine. Supported: 'sandbox' (the Windows Sandbox winapp manages) and 'local' (the default). There is no fallback: if the target cannot be prepared, the command fails rather than running here.",
"hidden": false,
"valueType": "System.String",
"hasDefaultValue": false,
"arity": {
"minimum": 1,
"maximum": 1
},
"required": false,
"recursive": true
}
},
"subcommands": {
"click": {
"description": "Click an element by slug or text search using mouse simulation. Works on elements that don't support InvokePattern (e.g., column headers, list items). Use --double for double-click, --right for right-click.",
Expand Down Expand Up @@ -6565,6 +6579,18 @@
"required": false,
"recursive": false
},
"--on": {
"description": "Run this command on the named execution target instead of this machine. Supported: 'sandbox' (the Windows Sandbox winapp manages) and 'local' (the default). There is no fallback: if the target cannot be prepared, the command fails rather than running here.",
"hidden": false,
"valueType": "System.String",
"hasDefaultValue": false,
"arity": {
"minimum": 1,
"maximum": 1
},
"required": false,
"recursive": true
},
"--output-appx-directory": {
"description": "The AppX layout directory the package was registered from. Only needed when the run used --output-appx-directory, since nothing on the package records which run option produced its layout; without it the registration looks like it came from a different tree and is skipped.",
"hidden": false,
Expand Down
28 changes: 14 additions & 14 deletions docs/npm-usage.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/winapp-CLI/WinApp.Cli.Tests/CustomHelpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,6 @@ public async Task SubcommandHelp_ShouldFallBackToDefault()
Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Subcommand help output should not be empty");

// Subcommand help should NOT contain category headers (those are root-only)
Assert.DoesNotContain("Packaging & Signing", output, "Subcommand help should not contain root-level categories");
Assert.DoesNotContain("Package, Sign & Publish", output, "Subcommand help should not contain root-level categories");
}
}
63 changes: 29 additions & 34 deletions src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetSelectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
namespace WinApp.Cli.Tests;

/// <summary>
/// Keeps the set of commands that honour <c>--on</c> honest, in both languages that describe it.
/// Keeps the set of commands that honour <c>--on</c> honest, and where it is advertised.
/// </summary>
[TestClass]
public partial class ExecutionTargetSelectionTests : BaseCommandTests
Expand Down Expand Up @@ -40,50 +40,45 @@ public void OnlyTheDesignedCommandsAreTargetAware()
}

/// <summary>
/// The npm generator emits an <c>on</c> property only for these trees, because everywhere else
/// the option exists solely to be rejected. The list is duplicated across a language boundary,
/// so it is asserted rather than assumed.
/// <c>--on</c> is shown only where it works. Every command still parses it (so it can be
/// rejected rather than absorbed by a positional argument), but help, completion, and the CLI
/// schema advertise it only on target-aware commands.
/// </summary>
[TestMethod]
public async Task TargetAwareCommands_MatchTheGeneratorList()
[DataRow(new[] { "run", "--help" }, true)]
[DataRow(new[] { "unregister", "--help" }, true)]
[DataRow(new[] { "ui", "--help" }, true)]
[DataRow(new[] { "ui", "click", "--help" }, true)]
[DataRow(new[] { "--help" }, false)]
[DataRow(new[] { "init", "--help" }, false)]
[DataRow(new[] { "cert", "generate", "--help" }, false)]
[DataRow(new[] { "target", "exec", "--help" }, false)]
public async Task Help_ShowsTheSelectorOnlyOnTargetAwareCommands(string[] args, bool expected)
{
var generator = Path.Join(
FindRepositoryRoot(), "src", "winapp-npm", "scripts", "generate-commands.mjs");

var source = await File.ReadAllTextAsync(generator, TestContext.CancellationToken);
var match = GeneratorListPattern().Match(source);

Assert.IsTrue(match.Success, $"Could not find TARGET_AWARE_COMMANDS in {generator}.");
var root = GetRequiredService<WinAppRootCommand>();

var listed = match.Groups[1].Value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(entry => entry.Trim('\'', '"'))
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
Assert.AreEqual(0, await ParseAndInvokeWithCaptureAsync(root, args));

CollectionAssert.AreEqual(
Expected.OrderBy(name => name, StringComparer.Ordinal).ToArray(),
listed,
"The npm generator's target-aware list has drifted from ITargetAwareCommand.");
Assert.AreEqual(
expected,
SelectorInHelpPattern().IsMatch(TestAnsiConsole.Output),
$"'winapp {string.Join(' ', args)}' {(expected ? "must" : "must not")} list --on.");
}


[GeneratedRegex(@"const TARGET_AWARE_COMMANDS = \[([^\]]*)\]")]
private static partial Regex GeneratorListPattern();

private static string FindRepositoryRoot()
[TestMethod]
public void TargetAwareCommands_DeclareTheVisibleSelector()
{
// Anchored on a file the repository is known to have rather than on `.git`, which is a file
// rather than a directory inside a worktree.
var directory = new DirectoryInfo(AppContext.BaseDirectory);
var root = GetRequiredService<WinAppRootCommand>();

while (directory is not null &&
!File.Exists(Path.Join(directory.FullName, "scripts", "build-cli.ps1")))
foreach (var command in root.Subcommands.OfType<ITargetAwareCommand>().Cast<Command>())
{
directory = directory.Parent;
CollectionAssert.Contains(command.Options.ToList(), ExecutionTargetSelection.OnOption, command.Name);
}

Assert.IsNotNull(directory, "Could not locate the repository root from the test output directory.");
return directory.FullName;
Assert.IsTrue(ExecutionTargetSelection.UnsupportedOnOption.Hidden);
CollectionAssert.DoesNotContain(root.Options.ToList(), ExecutionTargetSelection.OnOption);
}

[GeneratedRegex(@"(^|\s)--on(\s|,|$)", RegexOptions.Multiline)]
private static partial Regex SelectorInHelpPattern();
}
19 changes: 19 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/TargetSelectionParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ public void TargetAwareCommand_ResolvesTheSelector(string command)
Assert.AreEqual(ExecutionTargetRef.SandboxKind, ExecutionTargetSelection.Resolve(parsed).Kind);
}

[TestMethod]
public void SelectorBeforeTheCommand_StillResolves()
{
var parsed = Parse(["--on", "sandbox", "run"]);

Assert.IsNull(ExecutionTargetSelection.Validate(parsed));
Assert.AreEqual(ExecutionTargetRef.SandboxKind, ExecutionTargetSelection.Resolve(parsed).Kind);
}

[TestMethod]
public void SelectorGivenBeforeAndAfterTheCommand_IsRefused()
{
var parsed = Parse(["--on", "local", "run", "--on", "sandbox"]);
var error = ExecutionTargetSelection.Validate(parsed);

Assert.IsNotNull(error);
Assert.AreEqual(ExecutionTargetErrorCodes.TargetInvalid, error.Code);
}

[TestMethod]
public void OmittedSelector_MeansThisMachine()
{
Expand Down
73 changes: 58 additions & 15 deletions src/winapp-CLI/WinApp.Cli/Commands/ExecutionTargetSelection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ internal interface ITargetAwareCommand;
/// </summary>
/// <remarks>
/// <para>
/// <c>--on</c> is registered recursively on the <em>root</em>, not on the target-aware commands. That
/// matters for safety rather than convenience: System.CommandLine binds an unrecognised token to a
/// nearby optional positional argument rather than failing, so a command that did not declare
/// <c>--on</c> is declared twice under the same name. <see cref="OnOption"/> is the visible one,
/// attached to each target-aware command, so help, completion, and the CLI schema show it only
/// where it works. <see cref="UnsupportedOnOption"/> is a hidden, recursive copy on the root, and
/// it matters for safety rather than convenience: System.CommandLine binds an unrecognised token to
/// a nearby optional positional argument rather than failing, so a command that did not parse
/// <c>--on</c> would happily swallow <c>--on sandbox</c> into its <c>selector</c> argument and then
/// run the command on this desktop, reporting success. Parsing the token everywhere and rejecting it
/// where it is meaningless removes that failure mode: a command either honours <c>--on</c> or says it
/// cannot.
/// run the command on this desktop, reporting success. The hidden copy makes every command parse
/// the token so it can be rejected where it is meaningless: a command either honours <c>--on</c>
/// or says it cannot. A command's own options take precedence over a parent's recursive ones, so
/// on a target-aware command the token binds to <see cref="OnOption"/>.
/// </para>
/// <para>
/// There is deliberately no short alias. <c>-o</c> is already <c>--output</c>, and <c>--target</c>
Expand All @@ -37,23 +40,44 @@ internal interface ITargetAwareCommand;
/// </remarks>
internal static class ExecutionTargetSelection
{
/// <summary>Selects where a target-aware command runs.</summary>
private const string OnOptionDescription =
"Run this command on the named execution target instead of this machine. " +
"Supported: 'sandbox' (the Windows Sandbox winapp manages) and 'local' (the default). " +
"There is no fallback: if the target cannot be prepared, the command fails rather than " +
"running here.";

/// <summary>
/// Selects where a target-aware command runs. Attached to each <see cref="ITargetAwareCommand"/>
/// root; recursive so every verb beneath one (each <c>ui</c> verb) accepts it too.
/// </summary>
public static Option<string?> OnOption { get; } = new("--on")
{
Description =
"Run this command on the named execution target instead of this machine. " +
"Supported: 'sandbox' (the Windows Sandbox winapp manages) and 'local' (the default). " +
"There is no fallback: if the target cannot be prepared, the command fails rather than " +
"running here.",
Description = OnOptionDescription,
Recursive = true,
};

/// <summary>
/// Hidden, recursive copy of <c>--on</c> on the root, so commands that cannot honour it still
/// parse it and reject it instead of absorbing it into a positional argument. Also catches
/// <c>--on</c> written before the subcommand (<c>winapp --on sandbox run</c>).
/// </summary>
public static Option<string?> UnsupportedOnOption { get; } = new("--on")
{
Description = OnOptionDescription,
Recursive = true,
Hidden = true,
};

/// <summary>Whether <paramref name="option"/> is either declaration of <c>--on</c>.</summary>
public static bool IsSelectorOption(Option option) =>
option == OnOption || option == UnsupportedOnOption;

/// <summary>The selector as typed, or null when <c>--on</c> was not supplied.</summary>
public static string? RawSelector(ParseResult parseResult)
{
ArgumentNullException.ThrowIfNull(parseResult);

if (parseResult.GetResult(OnOption) is not { Implicit: false } result)
if (SuppliedResult(parseResult) is not { } result)
{
return null;
}
Expand All @@ -72,8 +96,18 @@ internal static class ExecutionTargetSelection
}

/// <summary>Whether the user supplied <c>--on</c> at all.</summary>
public static bool WasSupplied(ParseResult parseResult) =>
parseResult.GetResult(OnOption) is { Implicit: false };
public static bool WasSupplied(ParseResult parseResult) => SuppliedResult(parseResult) is not null;

private static OptionResult? SuppliedResult(ParseResult parseResult) =>
parseResult.GetResult(OnOption) is { Implicit: false } onResult
? onResult
: parseResult.GetResult(UnsupportedOnOption) is { Implicit: false } unsupportedResult
? unsupportedResult
: null;

private static bool WasSuppliedTwice(ParseResult parseResult) =>
parseResult.GetResult(OnOption) is { Implicit: false } &&
parseResult.GetResult(UnsupportedOnOption) is { Implicit: false };

/// <summary>Whether the selected command can run anywhere but this machine.</summary>
public static bool IsTargetAware(ParseResult parseResult)
Expand Down Expand Up @@ -152,6 +186,15 @@ public static ExecutionTargetRef Resolve(ParseResult parseResult)
context: new Dictionary<string, string> { ["command"] = name }).Error;
}

if (WasSuppliedTwice(parseResult))
{
return ExecutionTargetException.Create(
ExecutionTargetErrorCodes.TargetInvalid,
"--on was given more than once.",
userAction: "Pass --on once, after the command name.",
example: "winapp run . --on sandbox").Error;
}

return ExecutionTargetSelector.TryParse(RawSelector(parseResult), out _, out var error)
? null
: error;
Expand Down
2 changes: 1 addition & 1 deletion src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ namespace WinApp.Cli.Commands;

internal partial class RunCommand : Command, IShortDescription, ITargetAwareCommand
{
public string ShortDescription => "Run a Windows app from a project, .NET file-based app, or build-output folder.";
public string ShortDescription => "Run a Windows app from a project, .NET file-based app, or build-output folder";

public static Argument<FileSystemInfo> InputArgument { get; }
public static Option<FileInfo> ManifestOption { get; }
Expand Down
2 changes: 1 addition & 1 deletion src/winapp-CLI/WinApp.Cli/Commands/UnregisterCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ namespace WinApp.Cli.Commands;

internal partial class UnregisterCommand : Command, IShortDescription, ITargetAwareCommand
{
public string ShortDescription => "Unregister a sideloaded development package.";
public string ShortDescription => "Unregister a sideloaded development package";

public static Argument<FileInfo> InputArgument { get; }
public static Option<FileInfo> ManifestOption { get; }
Expand Down
Loading
Loading