From 5e95a3426cb4b7091f8a402d52bd54e6232c516e Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Mon, 5 Oct 2026 15:16:25 +0200 Subject: [PATCH 1/3] Reorder root help around the app development workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/cli-schema.json | 2 +- .../WinApp.Cli.Tests/CustomHelpTests.cs | 2 +- src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs | 2 +- .../WinApp.Cli/Commands/UnregisterCommand.cs | 2 +- .../WinApp.Cli/Commands/WinAppRootCommand.cs | 15 ++++++++------- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 0d73fa77f..ea85346d2 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -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 app framework targeting Windows, and manages Windows SDKs, package identity, manifests, and certificates.", "hidden": false, "options": { "--cli-schema": { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CustomHelpTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CustomHelpTests.cs index 4a553e491..c96c29330 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/CustomHelpTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/CustomHelpTests.cs @@ -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"); } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs index 37c6ce9eb..df6af0a36 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs @@ -21,7 +21,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 InputArgument { get; } public static Option ManifestOption { get; } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UnregisterCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UnregisterCommand.cs index 28b683e31..cf36b4bec 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UnregisterCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UnregisterCommand.cs @@ -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 InputArgument { get; } public static Option ManifestOption { get; } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index 40c00a941..7e64d016a 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -11,7 +11,7 @@ namespace WinApp.Cli.Commands; internal class WinAppRootCommand : RootCommand, IShortDescription { - public string ShortDescription => "Tools for Windows app development, package identity, packaging, and the Windows (App) SDK"; + public string ShortDescription => "Create, run, debug, test, and package Windows apps"; internal static Option VerboseOption = new Option("--verbose", "-v") { Description = "Enable verbose output" @@ -88,7 +88,7 @@ public WinAppRootCommand( GuestRuntimeCommand guestRuntimeCommand, GuestLaunchCommand guestLaunchCommand, TargetCommand targetCommand, - FindApiCommand findApiCommand) : base("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") + FindApiCommand findApiCommand) : base("Create, run, debug, test, and package Windows apps from the command line. Works with WinUI and any other app framework targeting Windows, and manages Windows SDKs, package identity, manifests, and certificates.") { Subcommands.Add(initCommand); Subcommands.Add(newCommand); @@ -128,11 +128,12 @@ public WinAppRootCommand( // Replace the default help with a custom categorized help screen var helpOption = Options.OfType().First(); helpOption.Action = new CustomHelpAction(this, ansiConsole, - ("Setup", [typeof(InitCommand), typeof(NewCommand), typeof(RestoreCommand), typeof(UpdateCommand)]), - ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(AzSignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(EmbedIdentityCommand), typeof(CreateExternalCatalogCommand)]), - ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand), typeof(RunCommand), typeof(UnregisterCommand), typeof(TargetCommand)]), - ("Discovery", [typeof(FindApiCommand), typeof(FindUiCommand)]), - ("UI Automation", [typeof(UiCommand)]) + ("Get Started", [typeof(NewCommand), typeof(InitCommand), typeof(RestoreCommand), typeof(UpdateCommand)]), + ("Run & Debug", [typeof(RunCommand), typeof(CreateDebugIdentityCommand), typeof(UnregisterCommand), typeof(TargetCommand)]), + ("Discovery", [typeof(FindUiCommand), typeof(FindApiCommand)]), + ("UI Automation", [typeof(UiCommand)]), + ("Package, Sign & Publish", [typeof(PackageCommand), typeof(ManifestCommand), typeof(CertCommand), typeof(SignCommand), typeof(AzSignCommand), typeof(MSStoreCommand)]), + ("Advanced", [typeof(EmbedIdentityCommand), typeof(CreateExternalCatalogCommand), typeof(ToolCommand), typeof(GetWinappPathCommand)]) ); } } From 0da54d9848715eff5472d232a993ccd5bfe77cfe Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Mon, 5 Oct 2026 15:19:11 +0200 Subject: [PATCH 2/3] Mention cross-platform frameworks in root description Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/cli-schema.json | 2 +- src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli-schema.json b/docs/cli-schema.json index ea85346d2..1ae0156d7 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -2,7 +2,7 @@ "name": "winapp", "version": "0.7.2", "schemaVersion": "1.0", - "description": "Create, run, debug, test, and package Windows apps from the command line. Works with WinUI and any other app framework targeting Windows, and manages Windows SDKs, package identity, manifests, and certificates.", + "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": { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index 7e64d016a..1450ec104 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -88,7 +88,7 @@ public WinAppRootCommand( GuestRuntimeCommand guestRuntimeCommand, GuestLaunchCommand guestLaunchCommand, TargetCommand targetCommand, - FindApiCommand findApiCommand) : base("Create, run, debug, test, and package Windows apps from the command line. Works with WinUI and any other app framework targeting Windows, and manages Windows SDKs, package identity, manifests, and certificates.") + FindApiCommand findApiCommand) : base("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.") { Subcommands.Add(initCommand); Subcommands.Add(newCommand); From 66b44259d6df52fe3debb6c314eb7e2ad67b52ea Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:46:50 -0700 Subject: [PATCH 3/3] Show --on only on commands that honour it Root help and every subcommand's help listed --on, including commands that reject it. Keep a hidden recursive --on on the root so every command still parses (and rejects) it, and attach the visible option only to target-aware commands (run, unregister, ui). The CLI schema now lists --on on those commands, so the npm generator no longer needs its own target-aware list. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/cli-schema.json | 50 ++++++++++--- docs/npm-usage.md | 28 +++---- .../ExecutionTargetSelectionTests.cs | 63 ++++++++-------- .../TargetSelectionParserTests.cs | 19 +++++ .../Commands/ExecutionTargetSelection.cs | 73 +++++++++++++++---- .../WinApp.Cli/Commands/WinAppRootCommand.cs | 7 +- .../Telemetry/Events/CommandInvokedEvent.cs | 2 +- src/winapp-npm/scripts/generate-commands.mjs | 62 +--------------- src/winapp-npm/src/winapp-commands.ts | 12 +-- 9 files changed, 175 insertions(+), 141 deletions(-) diff --git a/docs/cli-schema.json b/docs/cli-schema.json index bbbb4b7e9..aa30b8321 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -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, @@ -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, @@ -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.", @@ -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, diff --git a/docs/npm-usage.md b/docs/npm-usage.md index e81b55c04..164b405b3 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -103,7 +103,7 @@ function certGenerate(options?: CertGenerateOptions): Promise | `manifest` | `string \| undefined` | No | Path to Package.appxmanifest or appxmanifest.xml file to extract publisher information from | | `output` | `string \| undefined` | No | Output path for the generated PFX file | | `password` | `string \| undefined` | No | Password for the generated PFX file. Defaults to 'password', which is publicly known — a certificate left with that password is development-only, because anyone who obtains the .pfx can sign as you. | -| `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. | +| `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). Components must be single-valued and comma-separated; multi-valued '+' RDNs, ';' separators, and backslashes are not supported. If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. | | `validDays` | `number \| undefined` | No | Number of days the certificate is valid | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* @@ -122,9 +122,9 @@ function certInfo(options: CertInfoOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| -| `certPath` | `string` | Yes | Path to the certificate file (PFX) | +| `certPath` | `string` | Yes | Path to the certificate file (PFX or CER) | | `json` | `boolean \| undefined` | No | Format output as JSON | -| `password` | `string \| undefined` | No | Password for the PFX file | +| `password` | `string \| undefined` | No | Password for the PFX file (ignored for a public CER) | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* @@ -473,7 +473,7 @@ function manifestGenerate(options?: ManifestGenerateOptions): Promise). Accepts any valid X.500 DN; bare names are auto-wrapped as CN=. | +| `publisherName` | `string \| undefined` | No | Publisher distinguished name (DN) (default: CN=). Accepts an X.500 DN with single-valued, comma-separated components (multi-valued '+' RDNs, ';' separators, and backslashes are not supported); bare names are auto-wrapped as CN=. | | `template` | `ManifestTemplates \| undefined` | No | Manifest template type: 'packaged' (full MSIX app, default) or 'sparse' (desktop app with package identity for Windows APIs) | | `version` | `string \| undefined` | No | App version in Major.Minor.Build.Revision format (e.g., 1.0.0.0). | @@ -595,7 +595,6 @@ function run(options?: RunOptions): Promise |----------|------|----------|-------------| | `input` | `string \| undefined` | No | Path to the app to run: a build-output folder, a .cs .NET file-based app, a .csproj project, a .sln/.slnx solution, or a directory containing one of those at its top level (default: current directory). | | `inputFolder` | `string \| undefined` | No | | -| `on` | `string \| undefined` | No | 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. | | `aot` | `boolean \| undefined` | No | Project mode: run the project's configured .NET Native AOT publish. Requires effective PublishAot=true. | | `arch` | `string \| undefined` | No | Project mode: target architecture (x64, arm64, or x86). Sets the canonical Windows RID and selects a matching platform-dependent publish profile when required by the effective build. Ignored in folder mode. Honored for a .cs file-based app too; when omitted, winapp builds for the current process architecture. Default: the current process architecture. | | `args` | `string \| undefined` | No | Command-line arguments to pass to the application. Alternatively, use -- followed by arguments to avoid escaping (e.g., winapp run . -- --flag value). | @@ -610,6 +609,7 @@ function run(options?: RunOptions): Promise | `noBuild` | `boolean \| undefined` | No | Project and single-file mode: skip building and run the existing build output (still evaluates output properties). Ignored in folder mode. | | `noLaunch` | `boolean \| undefined` | No | Only create the debug identity and register the package without launching the application | | `noRestore` | `boolean \| undefined` | No | Project and single-file mode: skip restoring before build or Native AOT publish. Ignored in folder mode. | +| `on` | `string \| undefined` | No | 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. | | `outputAppxDirectory` | `string \| undefined` | No | Output directory for the loose layout package. If not specified, a directory named AppX inside the input directory will be used. | | `project` | `string \| undefined` | No | Project mode: when the input is a solution (.sln/.slnx) or a directory with multiple runnable app projects, selects which project to launch (by name or path). Ignored in folder mode. Rejected for a .cs file-based app, which is itself the project. | | `property` | `string \| string[] \| undefined` | No | Project and single-file mode: MSBuild property as Name=Value, forwarded to both build and evaluation. Repeatable. Ignored in folder mode. | @@ -1160,7 +1160,7 @@ function uiSendKeys(options?: UiSendKeysOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| -| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | +| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Hold capslock or insert for screen-reader commands (ctrl+capslock+f12 toggles Narrator developer mode); these require --via send-input. Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | | `on` | `string \| undefined` | No | 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. | | `allowSystemKeys` | `boolean \| undefined` | No | Allow synthesizing system-/shell-reserved combos (win+, alt+f4, alt+tab, ctrl+esc, …) via --via send-input, which are refused by default because they act on the OS/shell beyond the target app. Opt in to drive global hotkeys (e.g. PowerToys' win+shift+v, win+r). No effect on --via post-message (already window-scoped; a warning is emitted if set without send-input). Note: win+l and ctrl+alt+del stay blocked even with this flag — win+l locks the workstation (LockWorkStation() via the shell hook), which is unrecoverable from automation, and ctrl+alt+del is a Secure Attention Sequence (SAS) that Windows drops from injected input regardless of this flag, so it can never take effect. | | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | @@ -1310,12 +1310,12 @@ function unregister(options?: UnregisterOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `input` | `string \| undefined` | No | Path to a .NET file-based app (a single .cs) whose package should be unregistered. Its identity is resolved the same way 'winapp run' resolves it, so no manifest path is needed. Omit to use --manifest or auto-detect a manifest in the current directory. Cannot be combined with --manifest. | -| `on` | `string \| undefined` | No | 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. | | `arch` | `string \| undefined` | No | Target architecture (x64, arm64, x86) used when resolving a .cs file-based app's identity (default: the current process architecture). Pass the same architecture the run used, since a Directory.Build.props can key identity off $(RuntimeIdentifier). Only applies to a .cs input. | | `configuration` | `string \| undefined` | No | Build configuration used when resolving a .cs file-based app's identity (default: Debug). Pass the same configuration the run used: a Directory.Build.props beside the .cs can set WinAppPackageName or WinAppManifestPath conditionally on $(Configuration). Only applies to a .cs input. | | `force` | `boolean \| undefined` | No | Skip the install-location directory check and unregister even if the package was registered from a different project tree. Candidates are matched by Identity/@Name alone, so with --force a same-named package from a different publisher is also removed, along with its application data — prefer --prune for registrations whose files are gone. With --prune, also skips the confirmation prompt. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from current directory) | +| `on` | `string \| undefined` | No | 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. | | `outputAppxDirectory` | `string \| undefined` | No | 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. | | `property` | `string \| string[] \| undefined` | No | MSBuild property (Name=Value) used when resolving a .cs file-based app's identity. Repeatable. Pass the same identity-affecting properties the run used (e.g. -p WinAppPackageName=...), since a command-line property overrides the file's own #:property directives. Only applies to a .cs input. | | `prune` | `boolean \| undefined` | No | Remove every development-mode registration whose files are gone. These can never launch — Windows keeps the identity and its Start menu entry, but activation silently does nothing. Lists what it found and asks before removing; pass --force to skip the prompt. Cannot be combined with an input or --manifest. | @@ -1740,7 +1740,7 @@ type ManifestTemplates = "packaged" | "sparse" | `manifest` | `string \| undefined` | No | Path to Package.appxmanifest or appxmanifest.xml file to extract publisher information from | | `output` | `string \| undefined` | No | Output path for the generated PFX file | | `password` | `string \| undefined` | No | Password for the generated PFX file. Defaults to 'password', which is publicly known — a certificate left with that password is development-only, because anyone who obtains the .pfx can sign as you. | -| `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. | +| `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). Components must be single-valued and comma-separated; multi-valued '+' RDNs, ';' separators, and backslashes are not supported. If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. | | `validDays` | `number \| undefined` | No | Number of days the certificate is valid | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | @@ -1752,9 +1752,9 @@ type ManifestTemplates = "packaged" | "sparse" | Property | Type | Required | Description | |----------|------|----------|-------------| -| `certPath` | `string` | Yes | Path to the certificate file (PFX) | +| `certPath` | `string` | Yes | Path to the certificate file (PFX or CER) | | `json` | `boolean \| undefined` | No | Format output as JSON | -| `password` | `string \| undefined` | No | Password for the PFX file | +| `password` | `string \| undefined` | No | Password for the PFX file (ignored for a public CER) | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | @@ -1991,7 +1991,7 @@ type ManifestTemplates = "packaged" | "sparse" | `ifExists` | `IfExists \| undefined` | No | Behavior when output file exists: 'error' (fail, default), 'skip' (keep existing), or 'overwrite' (replace) | | `logoPath` | `string \| undefined` | No | Path to logo image file | | `packageName` | `string \| undefined` | No | Package name (default: folder name) | -| `publisherName` | `string \| undefined` | No | Publisher distinguished name (DN) (default: CN=). Accepts any valid X.500 DN; bare names are auto-wrapped as CN=. | +| `publisherName` | `string \| undefined` | No | Publisher distinguished name (DN) (default: CN=). Accepts an X.500 DN with single-valued, comma-separated components (multi-valued '+' RDNs, ';' separators, and backslashes are not supported); bare names are auto-wrapped as CN=. | | `template` | `ManifestTemplates \| undefined` | No | Manifest template type: 'packaged' (full MSIX app, default) or 'sparse' (desktop app with package identity for Windows APIs) | | `version` | `string \| undefined` | No | App version in Major.Minor.Build.Revision format (e.g., 1.0.0.0). | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | @@ -2078,7 +2078,6 @@ type ManifestTemplates = "packaged" | "sparse" |----------|------|----------|-------------| | `input` | `string \| undefined` | No | Path to the app to run: a build-output folder, a .cs .NET file-based app, a .csproj project, a .sln/.slnx solution, or a directory containing one of those at its top level (default: current directory). | | `inputFolder` | `string \| undefined` | No | | -| `on` | `string \| undefined` | No | 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. | | `aot` | `boolean \| undefined` | No | Project mode: run the project's configured .NET Native AOT publish. Requires effective PublishAot=true. | | `arch` | `string \| undefined` | No | Project mode: target architecture (x64, arm64, or x86). Sets the canonical Windows RID and selects a matching platform-dependent publish profile when required by the effective build. Ignored in folder mode. Honored for a .cs file-based app too; when omitted, winapp builds for the current process architecture. Default: the current process architecture. | | `args` | `string \| undefined` | No | Command-line arguments to pass to the application. Alternatively, use -- followed by arguments to avoid escaping (e.g., winapp run . -- --flag value). | @@ -2093,6 +2092,7 @@ type ManifestTemplates = "packaged" | "sparse" | `noBuild` | `boolean \| undefined` | No | Project and single-file mode: skip building and run the existing build output (still evaluates output properties). Ignored in folder mode. | | `noLaunch` | `boolean \| undefined` | No | Only create the debug identity and register the package without launching the application | | `noRestore` | `boolean \| undefined` | No | Project and single-file mode: skip restoring before build or Native AOT publish. Ignored in folder mode. | +| `on` | `string \| undefined` | No | 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. | | `outputAppxDirectory` | `string \| undefined` | No | Output directory for the loose layout package. If not specified, a directory named AppX inside the input directory will be used. | | `project` | `string \| undefined` | No | Project mode: when the input is a solution (.sln/.slnx) or a directory with multiple runnable app projects, selects which project to launch (by name or path). Ignored in folder mode. Rejected for a .cs file-based app, which is itself the project. | | `property` | `string \| string[] \| undefined` | No | Project and single-file mode: MSBuild property as Name=Value, forwarded to both build and evaluation. Repeatable. Ignored in folder mode. | @@ -2475,7 +2475,7 @@ type ManifestTemplates = "packaged" | "sparse" | Property | Type | Required | Description | |----------|------|----------|-------------| -| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | +| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Hold capslock or insert for screen-reader commands (ctrl+capslock+f12 toggles Narrator developer mode); these require --via send-input. Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | | `on` | `string \| undefined` | No | 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. | | `allowSystemKeys` | `boolean \| undefined` | No | Allow synthesizing system-/shell-reserved combos (win+, alt+f4, alt+tab, ctrl+esc, …) via --via send-input, which are refused by default because they act on the OS/shell beyond the target app. Opt in to drive global hotkeys (e.g. PowerToys' win+shift+v, win+r). No effect on --via post-message (already window-scoped; a warning is emitted if set without send-input). Note: win+l and ctrl+alt+del stay blocked even with this flag — win+l locks the workstation (LockWorkStation() via the shell hook), which is unrecoverable from automation, and ctrl+alt+del is a Secure Attention Sequence (SAS) that Windows drops from injected input regardless of this flag, so it can never take effect. | | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | @@ -2583,12 +2583,12 @@ type ManifestTemplates = "packaged" | "sparse" | Property | Type | Required | Description | |----------|------|----------|-------------| | `input` | `string \| undefined` | No | Path to a .NET file-based app (a single .cs) whose package should be unregistered. Its identity is resolved the same way 'winapp run' resolves it, so no manifest path is needed. Omit to use --manifest or auto-detect a manifest in the current directory. Cannot be combined with --manifest. | -| `on` | `string \| undefined` | No | 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. | | `arch` | `string \| undefined` | No | Target architecture (x64, arm64, x86) used when resolving a .cs file-based app's identity (default: the current process architecture). Pass the same architecture the run used, since a Directory.Build.props can key identity off $(RuntimeIdentifier). Only applies to a .cs input. | | `configuration` | `string \| undefined` | No | Build configuration used when resolving a .cs file-based app's identity (default: Debug). Pass the same configuration the run used: a Directory.Build.props beside the .cs can set WinAppPackageName or WinAppManifestPath conditionally on $(Configuration). Only applies to a .cs input. | | `force` | `boolean \| undefined` | No | Skip the install-location directory check and unregister even if the package was registered from a different project tree. Candidates are matched by Identity/@Name alone, so with --force a same-named package from a different publisher is also removed, along with its application data — prefer --prune for registrations whose files are gone. With --prune, also skips the confirmation prompt. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from current directory) | +| `on` | `string \| undefined` | No | 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. | | `outputAppxDirectory` | `string \| undefined` | No | 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. | | `property` | `string \| string[] \| undefined` | No | MSBuild property (Name=Value) used when resolving a .cs file-based app's identity. Repeatable. Pass the same identity-affecting properties the run used (e.g. -p WinAppPackageName=...), since a command-line property overrides the file's own #:property directives. Only applies to a .cs input. | | `prune` | `boolean \| undefined` | No | Remove every development-mode registration whose files are gone. These can never launch — Windows keeps the identity and its Start menu entry, but activation silently does nothing. Lists what it found and asks before removing; pass --force to skip the prompt. Cannot be combined with an input or --manifest. | diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetSelectionTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetSelectionTests.cs index 31a2c4591..26f16e0dd 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetSelectionTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetSelectionTests.cs @@ -9,7 +9,7 @@ namespace WinApp.Cli.Tests; /// -/// Keeps the set of commands that honour --on honest, in both languages that describe it. +/// Keeps the set of commands that honour --on honest, and where it is advertised. /// [TestClass] public partial class ExecutionTargetSelectionTests : BaseCommandTests @@ -40,50 +40,45 @@ public void OnlyTheDesignedCommandsAreTargetAware() } /// - /// The npm generator emits an on 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. + /// --on 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. /// [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(); - 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(); - while (directory is not null && - !File.Exists(Path.Join(directory.FullName, "scripts", "build-cli.ps1"))) + foreach (var command in root.Subcommands.OfType().Cast()) { - 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(); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TargetSelectionParserTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TargetSelectionParserTests.cs index 20a702ad6..6b3ca4ef0 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TargetSelectionParserTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TargetSelectionParserTests.cs @@ -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() { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/ExecutionTargetSelection.cs b/src/winapp-CLI/WinApp.Cli/Commands/ExecutionTargetSelection.cs index e5db97372..ec737486c 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/ExecutionTargetSelection.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/ExecutionTargetSelection.cs @@ -22,13 +22,16 @@ internal interface ITargetAwareCommand; /// /// /// -/// --on is registered recursively on the root, 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 +/// --on is declared twice under the same name. is the visible one, +/// attached to each target-aware command, so help, completion, and the CLI schema show it only +/// where it works. 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 /// --on would happily swallow --on sandbox into its selector 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 --on 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 --on +/// 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 . /// /// /// There is deliberately no short alias. -o is already --output, and --target @@ -37,23 +40,44 @@ internal interface ITargetAwareCommand; /// internal static class ExecutionTargetSelection { - /// Selects where a target-aware command runs. + 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."; + + /// + /// Selects where a target-aware command runs. Attached to each + /// root; recursive so every verb beneath one (each ui verb) accepts it too. + /// public static Option 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, + }; + + /// + /// Hidden, recursive copy of --on 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 + /// --on written before the subcommand (winapp --on sandbox run). + /// + public static Option UnsupportedOnOption { get; } = new("--on") + { + Description = OnOptionDescription, Recursive = true, + Hidden = true, }; + /// Whether is either declaration of --on. + public static bool IsSelectorOption(Option option) => + option == OnOption || option == UnsupportedOnOption; + /// The selector as typed, or null when --on was not supplied. 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; } @@ -72,8 +96,18 @@ internal static class ExecutionTargetSelection } /// Whether the user supplied --on at all. - 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 }; /// Whether the selected command can run anywhere but this machine. public static bool IsTargetAware(ParseResult parseResult) @@ -152,6 +186,15 @@ public static ExecutionTargetRef Resolve(ParseResult parseResult) context: new Dictionary { ["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; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index 1450ec104..cf69c86a1 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -120,7 +120,12 @@ public WinAppRootCommand( Options.Add(CliSchemaOption); Options.Add(CallerOption); Options.Add(ProjectFrameworkOption); - Options.Add(ExecutionTargetSelection.OnOption); + Options.Add(ExecutionTargetSelection.UnsupportedOnOption); + + foreach (var targetAwareCommand in Subcommands.OfType().Cast()) + { + targetAwareCommand.Options.Add(ExecutionTargetSelection.OnOption); + } // Reject unknown options/arguments so typos and removed flags fail loudly TreatUnmatchedTokensAsErrors = true; diff --git a/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandInvokedEvent.cs b/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandInvokedEvent.cs index d25d6aaff..97837b808 100644 --- a/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandInvokedEvent.cs +++ b/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandInvokedEvent.cs @@ -48,7 +48,7 @@ internal static string ResolveTargetKind(CommandResult commandResult) { foreach (var option in command.Children.OfType()) { - if (option.Option != ExecutionTargetSelection.OnOption || option.Implicit) + if (!ExecutionTargetSelection.IsSelectorOption(option.Option) || option.Implicit) { continue; } diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index d162d95b2..766dd6adc 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -113,23 +113,6 @@ const OPTION_PROP_RENAMES = { 'target exec': { '--cwd': 'targetCwd' }, }; -/** - * Command trees that honour `--on`. - * - * `--on` is registered recursively on the winapp root so that *every* command parses the token - * consistently — that is a parser-safety property, not an API one: a command that did not declare - * it would let System.CommandLine bind `--on sandbox` to a nearby positional argument and then run - * on this machine while reporting success. Commands outside this list parse `--on` only to reject - * it, so emitting an `on` property on their wrappers would advertise an option that always fails. - * - * Kept in step with `ITargetAwareCommand` in the CLI by - * `ExecutionTargetSelectionTests.TargetAwareCommands_MatchTheGeneratorList`. - */ -const TARGET_AWARE_COMMANDS = ['run', 'ui', 'unregister']; - -/** The recursive selector option, which only target-aware commands should expose. */ -const TARGET_SELECTOR_OPTION = '--on'; - /** * Nullable enum types — strip `System.Nullable<...>` wrapper. */ @@ -233,19 +216,10 @@ function collectRecursiveOptions(cmd, inherited) { return { ...inherited, ...recursive }; } -function flattenCommands(node, parentPath = [], inherited = null) { +function flattenCommands(node, parentPath = [], inherited = {}) { const results = []; const subs = node.subcommands || {}; - // `--on` is declared once, on the root, so that every command parses it and a misspelling can - // never be absorbed by a positional argument. Groups have their recursive options collected on - // the way down; the root does not, so the selector is seeded here. - // - // Only the selector. The root's other recursive options (`--cli-schema`, `--caller`) describe - // winapp itself rather than the command, and putting them on every wrapper would offer callers a - // property that prints a schema instead of doing what they asked. - const inheritedOptions = inherited ?? rootSelectorOption(node); - for (const [name, cmd] of Object.entries(subs)) { if (cmd.hidden) continue; const cmdPath = [...parentPath, name]; @@ -255,44 +229,16 @@ function flattenCommands(node, parentPath = [], inherited = null) { // e.g. `find-api `) must be emitted as its own command in addition // to its subcommands, or the bare form gets no wrapper. if (cmd.arguments && Object.keys(cmd.arguments).length > 0) { - results.push({ - path: cmdPath, - cmd: inheritRecursiveOptions(cmd, dropUnsupportedSelector(cmdPath, inheritedOptions)), - }); + results.push({ path: cmdPath, cmd: inheritRecursiveOptions(cmd, inherited) }); } - results.push(...flattenCommands(cmd, cmdPath, collectRecursiveOptions(cmd, inheritedOptions))); + results.push(...flattenCommands(cmd, cmdPath, collectRecursiveOptions(cmd, inherited))); } else { - results.push({ - path: cmdPath, - cmd: inheritRecursiveOptions(cmd, dropUnsupportedSelector(cmdPath, inheritedOptions)), - }); + results.push({ path: cmdPath, cmd: inheritRecursiveOptions(cmd, inherited) }); } } return results; } -function rootSelectorOption(root) { - const selector = (root.options || {})[TARGET_SELECTOR_OPTION]; - return selector ? { [TARGET_SELECTOR_OPTION]: selector } : {}; -} - -/** - * Removes `--on` from what a leaf inherits unless that leaf can actually honour it. - * - * See `TARGET_AWARE_COMMANDS`: the CLI parses the option everywhere so a misspelling cannot be - * absorbed by a positional argument, but only these trees do anything with it. A wrapper that - * offered `on` on, say, `certInfo` would be offering a property whose only possible outcome is a - * non-zero exit. - */ -function dropUnsupportedSelector(cmdPath, inherited) { - if (TARGET_AWARE_COMMANDS.includes(cmdPath[0]) || !(TARGET_SELECTOR_OPTION in inherited)) { - return inherited; - } - - const { [TARGET_SELECTOR_OPTION]: _dropped, ...rest } = inherited; - return rest; -} - // --------------------------------------------------------------------------- // Generate TS source // --------------------------------------------------------------------------- diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 069a8b790..16e611f02 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -895,8 +895,6 @@ export interface RunOptions extends CommonOptions { input?: string; /** @deprecated Use `input` instead. Retained for backward compatibility. */ inputFolder?: string; - /** 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. */ - on?: string; /** Project mode: run the project's configured .NET Native AOT publish. Requires effective PublishAot=true. */ aot?: boolean; /** Project mode: target architecture (x64, arm64, or x86). Sets the canonical Windows RID and selects a matching platform-dependent publish profile when required by the effective build. Ignored in folder mode. Honored for a .cs file-based app too; when omitted, winapp builds for the current process architecture. Default: the current process architecture. */ @@ -925,6 +923,8 @@ export interface RunOptions extends CommonOptions { noLaunch?: boolean; /** Project and single-file mode: skip restoring before build or Native AOT publish. Ignored in folder mode. */ noRestore?: boolean; + /** 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. */ + on?: string; /** Output directory for the loose layout package. If not specified, a directory named AppX inside the input directory will be used. */ outputAppxDirectory?: string; /** Project mode: when the input is a solution (.sln/.slnx) or a directory with multiple runnable app projects, selects which project to launch (by name or path). Ignored in folder mode. Rejected for a .cs file-based app, which is itself the project. */ @@ -952,7 +952,6 @@ export async function run(options: RunOptions = {}): Promise { const args: string[] = ['run']; const inputValue = options.input ?? options.inputFolder; if (inputValue) args.push(inputValue); - if (options.on !== undefined) args.push('--on', options.on); if (options.aot) args.push('--aot'); if (options.arch !== undefined) args.push('--arch', options.arch); if (options.args !== undefined) args.push('--args', options.args); @@ -967,6 +966,7 @@ export async function run(options: RunOptions = {}): Promise { if (options.noBuild) args.push('--no-build'); if (options.noLaunch) args.push('--no-launch'); if (options.noRestore) args.push('--no-restore'); + if (options.on !== undefined) args.push('--on', options.on); if (options.outputAppxDirectory !== undefined) args.push('--output-appx-directory', options.outputAppxDirectory); if (options.project !== undefined) args.push('--project', options.project); if (options.property) { @@ -2086,8 +2086,6 @@ export async function uiYield(options: UiYieldOptions = {}): Promise